@hachej/boring-core 0.1.41 → 0.1.43
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 +28 -279
- package/dist/PostgresMeteringStore-CzNv6xil.d.ts +224 -0
- package/dist/app/front/index.d.ts +212 -3
- package/dist/app/front/index.js +820 -44
- package/dist/app/server/index.d.ts +3 -3
- package/dist/app/server/index.js +13 -6
- package/dist/{authHook-DUqyxueY.d.ts → authHook-CzBsMwwM.d.ts} +2 -2
- package/dist/{chunk-C3YMOITB.js → chunk-I56OTSPB.js} +649 -6
- package/dist/{chunk-H5KU6R6Y.js → chunk-LIBHVT7V.js} +5 -1
- package/dist/{chunk-GZVKZD4P.js → chunk-UM5SHYIS.js} +11 -2
- package/dist/{chunk-WYTCJ5WL.js → chunk-VYXEXOCO.js} +69 -26
- package/dist/{connection-AL8KSENV.d.ts → connection-C5SiqoNc.d.ts} +1 -1
- package/dist/front/index.d.ts +15 -2
- package/dist/front/index.js +2 -2
- package/dist/server/db/index.d.ts +4 -4
- package/dist/server/db/index.js +6 -2
- package/dist/server/index.d.ts +594 -7
- package/dist/server/index.js +1467 -4
- package/dist/shared/index.d.ts +1 -1
- package/dist/shared/index.js +1 -1
- package/dist/{types-CbMOXLBf.d.ts → types-CWtJ4kgd.d.ts} +3 -0
- package/drizzle/0011_usage_metering.sql +57 -0
- package/drizzle/0012_credit_purchases.sql +9 -0
- package/drizzle/0013_credit_purchase_lifecycle.sql +28 -0
- package/drizzle/0014_reservation_charge_on_expire.sql +7 -0
- package/drizzle/meta/_journal.json +28 -0
- package/package.json +4 -4
- package/dist/migrate-B4dwdtGP.d.ts +0 -8
package/README.md
CHANGED
|
@@ -7,314 +7,63 @@
|
|
|
7
7
|
|
|
8
8
|
</div>
|
|
9
9
|
|
|
10
|
-
The foundation package for boring-ui apps: Postgres/Drizzle database
|
|
10
|
+
The foundation package for boring-ui v2 apps: Postgres/Drizzle database, better-auth
|
|
11
|
+
(email/password, verification, password reset, magic links, optional Google), TOML+env
|
|
12
|
+
config loader, Fastify HTTP app factory, and a React frontend shell with auth/workspace
|
|
13
|
+
gating. Every child app imports core first; domain logic, agent runtime, and workspace UI
|
|
14
|
+
come from the sibling `@hachej/boring-*` packages.
|
|
11
15
|
|
|
12
16
|
```bash
|
|
13
17
|
pnpm add @hachej/boring-core
|
|
14
18
|
```
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
## TL;DR
|
|
19
|
-
|
|
20
|
-
**The Problem**: Building a multi-user agent-powered app means re-implementing auth, sessions, workspaces, invites, email flows, and an app shell every single time. These are the same across every deployment.
|
|
21
|
-
|
|
22
|
-
**The Solution**: `@hachej/boring-core` provides a complete app skeleton — Postgres DB, better-auth with email verification + password reset + magic links, workspace membership with roles, email transport (Resend/SMTP/console), and a `<BoringApp>` React shell with auth pages. You bring the domain logic.
|
|
23
|
-
|
|
24
|
-
### Why Use @hachej/boring-core?
|
|
25
|
-
|
|
26
|
-
| Feature | What It Does |
|
|
27
|
-
|---------|--------------|
|
|
28
|
-
| **Full auth suite** | Email/password + email verification + password reset + magic links (better-auth) |
|
|
29
|
-
| **Workspace management** | Create, update, delete workspaces; member roles (owner/editor/viewer); invites |
|
|
30
|
-
| **Fastify app factory** | Pre-wired with helmet, CORS, rate limiting, secret redaction, graceful shutdown |
|
|
31
|
-
| **Drizzle + Postgres** | Ready-to-run schema for users, workspaces, members, invites, settings |
|
|
32
|
-
| **Email transport** | Resend (default), SMTP, or console — pluggable via URL scheme |
|
|
33
|
-
| **<BoringApp> shell** | Client-rendered React shell with auth gate, theme toggle, workspace switcher |
|
|
34
|
-
| **Config loader** | TOML + env vars merged, Zod-validated, redacted for frontend |
|
|
20
|
+
## Usage essentials
|
|
35
21
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
## Quick Example
|
|
22
|
+
Most apps use the composed `app/*` surfaces, which fuse core + workspace + agent:
|
|
39
23
|
|
|
40
24
|
```ts
|
|
41
|
-
//
|
|
42
|
-
import {
|
|
43
|
-
|
|
44
|
-
const config = await loadConfig()
|
|
45
|
-
const app = await createCoreApp(config) // Fastify + DB + auth + routes
|
|
25
|
+
// server entry
|
|
26
|
+
import { createCoreWorkspaceAgentServer } from "@hachej/boring-core/app/server"
|
|
46
27
|
|
|
28
|
+
const app = await createCoreWorkspaceAgentServer({ plugins })
|
|
47
29
|
await app.listen({ port: 3000 })
|
|
48
30
|
```
|
|
49
31
|
|
|
50
32
|
```tsx
|
|
51
|
-
//
|
|
52
|
-
<BoringApp>
|
|
53
|
-
<Route path="/workspace/:id" element={<WorkspaceRoute />} />
|
|
54
|
-
<Route path="/settings" element={<Settings />} />
|
|
55
|
-
</BoringApp>
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
```tsx
|
|
59
|
-
// In your components — typed auth + workspace access
|
|
60
|
-
const user = useUser()
|
|
61
|
-
const workspace = useCurrentWorkspace()
|
|
62
|
-
const role = useWorkspaceRole() // 'owner' | 'editor' | 'viewer'
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
---
|
|
66
|
-
|
|
67
|
-
## Design Philosophy
|
|
68
|
-
|
|
69
|
-
1. **Core owns persistence and identity** — DB tables, auth, sessions, workspaces, invites. Everything else injects stores via interfaces.
|
|
70
|
-
2. **One config source** — `boring.app.toml` + environment variables merged, Zod-validated at boot. No scattered config.
|
|
71
|
-
3. **Email flows are real, not stubs** — password reset, email verification, magic links, workspace invites — all shipped with React Email templates.
|
|
72
|
-
4. **Swap seams, not rewrites** — `AuthProvider`, `UserStore`, `WorkspaceStore` are interfaces. The default impl is Postgres; swap via `createCoreApp({ authProvider })`.
|
|
73
|
-
5. **Fail closed on auth** — config fetch failure throws a `ConfigFetchError` with retries. Users see "Cannot reach server" not a blank page.
|
|
74
|
-
|
|
75
|
-
---
|
|
76
|
-
|
|
77
|
-
## Installation
|
|
78
|
-
|
|
79
|
-
```bash
|
|
80
|
-
# pnpm
|
|
81
|
-
pnpm add @hachej/boring-core @hachej/boring-workspace
|
|
82
|
-
|
|
83
|
-
# npm
|
|
84
|
-
npm install @hachej/boring-core @hachej/boring-workspace
|
|
85
|
-
|
|
86
|
-
# from source
|
|
87
|
-
git clone https://github.com/hachej/boring-ui.git
|
|
88
|
-
cd boring-ui && pnpm install
|
|
89
|
-
pnpm --filter @hachej/boring-core build
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
### Dependencies
|
|
93
|
-
|
|
94
|
-
Postgres is required for production. For dev, set `CORE_STORES=local` and core runs in-memory (state resets on restart).
|
|
95
|
-
|
|
96
|
-
---
|
|
97
|
-
|
|
98
|
-
## Quick Start
|
|
99
|
-
|
|
100
|
-
### 1. Set Environment
|
|
101
|
-
|
|
102
|
-
```bash
|
|
103
|
-
# .env
|
|
104
|
-
DATABASE_URL=postgres://user:pass@localhost:5432/myapp
|
|
105
|
-
BETTER_AUTH_SECRET=<32-byte random hex>
|
|
106
|
-
BETTER_AUTH_URL=http://localhost:3000
|
|
107
|
-
WORKSPACE_SETTINGS_ENCRYPTION_KEY=<32-byte hex>
|
|
108
|
-
MAIL_FROM=noreply@myapp.dev
|
|
109
|
-
MAIL_TRANSPORT_URL=resend://re_xxxxxxxxxxxxxxxx
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
### 2. Create Config File
|
|
113
|
-
|
|
114
|
-
```toml
|
|
115
|
-
# boring.app.toml
|
|
116
|
-
[app]
|
|
117
|
-
id = "my-app"
|
|
118
|
-
|
|
119
|
-
[frontend.branding]
|
|
120
|
-
name = "My App"
|
|
121
|
-
logo = "/logo.svg"
|
|
122
|
-
|
|
123
|
-
[features]
|
|
124
|
-
invites_enabled = true
|
|
125
|
-
invite_ttl_days = 7
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
### 3. Run Migrations
|
|
129
|
-
|
|
130
|
-
```bash
|
|
131
|
-
pnpm drizzle-kit generate --config node_modules/@hachej/boring-core/drizzle.config.ts
|
|
132
|
-
pnpm drizzle-kit migrate --config node_modules/@hachej/boring-core/drizzle.config.ts
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
### 4. Server Entry
|
|
136
|
-
|
|
137
|
-
```ts
|
|
138
|
-
import { createCoreApp, loadConfig } from "@hachej/boring-core/server"
|
|
139
|
-
|
|
140
|
-
const config = await loadConfig()
|
|
141
|
-
const app = await createCoreApp(config)
|
|
142
|
-
|
|
143
|
-
// add child-app routes
|
|
144
|
-
app.get("/api/v1/my-thing", async () => ({ ok: true }))
|
|
145
|
-
|
|
146
|
-
await app.listen({ port: config.port })
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
### 5. Frontend Entry
|
|
150
|
-
|
|
151
|
-
```tsx
|
|
33
|
+
// frontend entry
|
|
152
34
|
import { createRoot } from "react-dom/client"
|
|
153
|
-
import {
|
|
154
|
-
import
|
|
155
|
-
import "@hachej/boring-core/theme.css"
|
|
35
|
+
import { CoreWorkspaceAgentFront } from "@hachej/boring-core/app/front"
|
|
36
|
+
import "@hachej/boring-core/app/front/styles.css"
|
|
156
37
|
|
|
157
38
|
createRoot(document.getElementById("root")!).render(
|
|
158
|
-
<
|
|
159
|
-
<Route path="/" element={<Dashboard />} />
|
|
160
|
-
</BoringApp>,
|
|
39
|
+
<CoreWorkspaceAgentFront apiBaseUrl="" chatEntryMode="chat-first" plugins={plugins} />,
|
|
161
40
|
)
|
|
162
41
|
```
|
|
163
42
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
## Package Surfaces
|
|
43
|
+
For a core-only app (no agent/workspace), use `createCoreApp(config)` from
|
|
44
|
+
`@hachej/boring-core/server` and `CoreFront` from `@hachej/boring-core/front`.
|
|
167
45
|
|
|
168
|
-
|
|
169
|
-
|--------|-------------|--------------|
|
|
170
|
-
| `@hachej/boring-core/server` | Node | `createCoreApp`, `loadConfig`, auth, stores, routes |
|
|
171
|
-
| `@hachej/boring-core/server/db` | Node | Drizzle schema, migrations, store interfaces |
|
|
172
|
-
| `@hachej/boring-core/front` | Browser | `<BoringApp>`, hooks, auth pages, components |
|
|
173
|
-
| `@hachej/boring-core/shared` | Any | `User`, `Workspace`, `HttpError`, `ErrorCode` types |
|
|
174
|
-
| `@hachej/boring-core/theme.css` | Browser | CSS theme tokens for the frontend shell |
|
|
175
|
-
| `@hachej/boring-core/app/front` | Browser | App composition helpers (`WorkspaceAgentFront`, etc.) |
|
|
176
|
-
| `@hachej/boring-core/app/server` | Node | App composition helpers (`createWorkspaceAgentApp`) |
|
|
46
|
+
### Required environment (production)
|
|
177
47
|
|
|
178
|
-
|
|
48
|
+
`DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`,
|
|
49
|
+
`WORKSPACE_SETTINGS_ENCRYPTION_KEY`, `MAIL_FROM`, `MAIL_TRANSPORT_URL`
|
|
50
|
+
(`resend://…`, `smtp://…`, or `console://`), `CORS_ORIGINS`. Config is also read from
|
|
51
|
+
`boring.app.toml`. For dev without Postgres, set `CORE_STORES=local` (in-memory, resets
|
|
52
|
+
on restart; not supported by `createCoreWorkspaceAgentServer`).
|
|
179
53
|
|
|
180
|
-
|
|
54
|
+
Migrations live in `drizzle/`; run them with `drizzle-kit migrate` against
|
|
55
|
+
`drizzle.config.ts`.
|
|
181
56
|
|
|
182
|
-
|
|
57
|
+
## Documentation
|
|
183
58
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
| `BETTER_AUTH_SECRET` | Yes | 32-byte hex — signs session cookies |
|
|
188
|
-
| `BETTER_AUTH_URL` | Yes | Public URL for OAuth callbacks |
|
|
189
|
-
| `WORKSPACE_SETTINGS_ENCRYPTION_KEY` | Yes (prod) | 32-byte hex — encrypts workspace settings |
|
|
190
|
-
| `MAIL_FROM` | Yes (prod) | Sender address for auth emails |
|
|
191
|
-
| `MAIL_TRANSPORT_URL` | Yes (prod) | `resend://key`, `smtp://host`, or `console://` |
|
|
192
|
-
| `CORE_STORES` | No | `postgres` (default) or `local` (in-memory dev) |
|
|
193
|
-
| `CORS_ORIGINS` | Yes (prod) | Comma-separated allowlist |
|
|
194
|
-
| `SEND_WELCOME_EMAIL` | No | Default `true` — suppress with `false` |
|
|
195
|
-
| `SESSION_TTL_SECONDS` | No | Default 2,592,000 (30 days) |
|
|
196
|
-
|
|
197
|
-
---
|
|
198
|
-
|
|
199
|
-
## Architecture
|
|
200
|
-
|
|
201
|
-
```
|
|
202
|
-
┌──────────────────────┐
|
|
203
|
-
│ Browser Client │
|
|
204
|
-
│ /auth/* + /me + │
|
|
205
|
-
│ /workspaces/* │
|
|
206
|
-
└──────────┬───────────┘
|
|
207
|
-
│ HTTP (typed, cookie auth)
|
|
208
|
-
┌──────────▼───────────┐
|
|
209
|
-
│ Fastify App │
|
|
210
|
-
│ ├── authHook (req.user)
|
|
211
|
-
│ ├── helmet + CORS │
|
|
212
|
-
│ ├── rate limits │
|
|
213
|
-
│ ├── secret redaction│
|
|
214
|
-
│ └── graceful shutdown
|
|
215
|
-
└──────────┬───────────┘
|
|
216
|
-
│
|
|
217
|
-
┌──────────▼───────────┐
|
|
218
|
-
│ better-auth │
|
|
219
|
-
│ (sessions, email, │
|
|
220
|
-
│ password reset) │
|
|
221
|
-
└──────────┬───────────┘
|
|
222
|
-
│
|
|
223
|
-
┌──────────▼───────────┐
|
|
224
|
-
│ Drizzle + Postgres │
|
|
225
|
-
│ users, sessions, │
|
|
226
|
-
│ workspaces, members, │
|
|
227
|
-
│ invites, settings │
|
|
228
|
-
└──────────────────────┘
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
### Error Handling Contract
|
|
232
|
-
|
|
233
|
-
All errors flow through a single `setErrorHandler`:
|
|
234
|
-
|
|
235
|
-
| Condition | Status | Code |
|
|
236
|
-
|-----------|--------|------|
|
|
237
|
-
| No/expired session | 401 | `unauthorized` |
|
|
238
|
-
| Insufficient role | 403 | `forbidden` / `not_member` |
|
|
239
|
-
| Zod validation fail | 400 | `validation_failed` |
|
|
240
|
-
| Rate limited | 429 | `rate_limited` + `Retry-After` |
|
|
241
|
-
| DB ping fails | 503 | `db_unavailable` |
|
|
242
|
-
| Everything else | 500 | `internal_error` |
|
|
243
|
-
|
|
244
|
-
Every response includes `{ error, code, message, requestId }`. Client-side `apiFetch` parses this into `HttpError` instances.
|
|
245
|
-
|
|
246
|
-
---
|
|
247
|
-
|
|
248
|
-
## How @hachej/boring-core Compares
|
|
249
|
-
|
|
250
|
-
| Feature | @hachej/boring-core | Supabase + custom | Firebase | Roll your own |
|
|
251
|
-
|---------|---------------------|-------------------|----------|---------------|
|
|
252
|
-
| Auth flows | ✅ email + reset + magic link | ✅ OAuth only | ✅ OAuth/phone | Weeks to build |
|
|
253
|
-
| Workspaces + invites | ✅ owner/editor/viewer roles | ❌ Custom tables | ❌ Custom rules | ~1 week |
|
|
254
|
-
| Email templates | ✅ 5 React Email templates | ❌ You write them | ❌ SendGrid setup | ~3 days |
|
|
255
|
-
| App shell | ✅ `<BoringApp>` + hooks | ❌ DIY | ❌ DIY | ~1 week |
|
|
256
|
-
| Rate limiting | ✅ pre-wired routes | ❌ Edge functions | ⚠️ Cloud rules | ~2 days |
|
|
257
|
-
| Config validation | ✅ TOML + env + Zod | ❌ dotenv only | ⚠️ Remote config | Custom |
|
|
258
|
-
|
|
259
|
-
**When to use @hachej/boring-core:**
|
|
260
|
-
- Building a multi-user app around an AI agent
|
|
261
|
-
- You need auth + workspaces + invites in days, not weeks
|
|
262
|
-
- You're deploying to Fly.io, Render, Railway, or any Postgres-capable host
|
|
263
|
-
|
|
264
|
-
**When it might not fit:**
|
|
265
|
-
- You need server-side rendering (client-rendered only)
|
|
266
|
-
- You want SQLite (Postgres-only with Drizzle)
|
|
267
|
-
- You need Google/Apple/Discord OAuth (planned for v1.x)
|
|
268
|
-
- You need billing/Stripe integration (future `@boring/cloud` package)
|
|
269
|
-
|
|
270
|
-
---
|
|
271
|
-
|
|
272
|
-
## Troubleshooting
|
|
273
|
-
|
|
274
|
-
| Error | Cause | Fix |
|
|
275
|
-
|-------|-------|-----|
|
|
276
|
-
| `ConfigValidationError` at boot | Missing required env var | Check `.env` has all required vars |
|
|
277
|
-
| `config_fetch_failed` in browser | API server not reachable | Verify `BETTER_AUTH_URL` matches |
|
|
278
|
-
| `mail_disabled` warning at boot | `MAIL_FROM` not set | Set `MAIL_TRANSPORT_URL=console://` for dev |
|
|
279
|
-
| `unauthorized` on `/api/v1/me` | No session cookie | Check `BETTER_AUTH_URL` and `CORS_ORIGINS` |
|
|
280
|
-
| `db_unavailable` on `/health` | Postgres can't connect | Verify `DATABASE_URL` and network access |
|
|
281
|
-
|
|
282
|
-
---
|
|
283
|
-
|
|
284
|
-
## Limitations
|
|
285
|
-
|
|
286
|
-
- **Postgres only** — No SQLite/libsql support in v1.
|
|
287
|
-
- **Client-rendered only** — `<BoringApp>` mounts client-side. No SSR.
|
|
288
|
-
- **GitHub OAuth deferred** — Planned for v1.x, bundled with agent's GitHub App install.
|
|
289
|
-
- **No billing** — Stripe integration planned for `@boring/cloud` package.
|
|
290
|
-
- **In-memory stores are dev-only** — `CORE_STORES=local` resets on restart. Not for production.
|
|
291
|
-
- **Partial swap seams** — `AuthProvider` is swappable, but the React auth surfaces (`useSession`, sign-in pages) are better-auth-shaped.
|
|
292
|
-
|
|
293
|
-
---
|
|
294
|
-
|
|
295
|
-
## FAQ
|
|
296
|
-
|
|
297
|
-
**Q: Can I use this without Postgres?**
|
|
298
|
-
A: In dev, yes — set `CORE_STORES=local`. State is in-memory and resets on restart. For production, Postgres is required.
|
|
299
|
-
|
|
300
|
-
**Q: How do I add Google/Discord OAuth?**
|
|
301
|
-
A: better-auth supports these out of the box. Add the provider config to `createAuth()` in the core source. Official v1.x support planned.
|
|
302
|
-
|
|
303
|
-
**Q: Can I swap better-auth for Clerk/Neon?**
|
|
304
|
-
A: The `AuthProvider` interface is designed as a swap seam. You'll need to re-implement the React auth surfaces (`SignInPage`, `useSession`, etc.) and preserve the `users.id` continuity invariant.
|
|
305
|
-
|
|
306
|
-
**Q: How do email templates work?**
|
|
307
|
-
A: Five React Email components (`VerifyEmail`, `ResetPassword`, `MagicLink`, `WorkspaceInvite`, `Welcome`) rendered via `@react-email/render`. CSS is inlined. Swap them by providing your own mail transport.
|
|
308
|
-
|
|
309
|
-
**Q: What's the difference between `@hachej/boring-core/server` and `@hachej/boring-core/server/db`?**
|
|
310
|
-
A: `server` includes the full Fastify app, routes, auth, and stores. `server/db` is the Drizzle schema + connection + store interfaces only — useful for migration tooling and type-only imports.
|
|
59
|
+
See [`docs/README.md`](./docs/README.md) for the architecture overview, public API
|
|
60
|
+
surface, key abstractions, and links to the gating, plugin, and deployment docs. The
|
|
61
|
+
reference app is [`apps/full-app`](../../apps/full-app/).
|
|
311
62
|
|
|
312
63
|
---
|
|
313
64
|
|
|
314
65
|
*About Contributions:* Please don't take this the wrong way, but I do not accept outside contributions for any of my projects. I simply don't have the mental bandwidth to review anything, and it's my name on the thing, so I'm responsible for any problems it causes; thus, the risk-reward is highly asymmetric from my perspective. I'd also have to worry about other "stakeholders," which seems unwise for tools I mostly make for myself for free. Feel free to submit issues, and even PRs if you want to illustrate a proposed fix, but know I won't merge them directly. Instead, I'll have Claude or Codex review submissions via `gh` and independently decide whether and how to address them. Bug reports in particular are welcome. Sorry if this offends, but I want to avoid wasted time and hurt feelings. I understand this isn't in sync with the prevailing open-source ethos that seeks community contributions, but it's the only way I can move at this velocity and keep my sanity.
|
|
315
66
|
|
|
316
|
-
---
|
|
317
|
-
|
|
318
67
|
## License
|
|
319
68
|
|
|
320
69
|
MIT
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { C as CoreConfig } from './types-CWtJ4kgd.js';
|
|
2
|
+
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
|
3
|
+
|
|
4
|
+
interface RunMigrationsOptions {
|
|
5
|
+
migrationsFolder?: string;
|
|
6
|
+
}
|
|
7
|
+
declare function runMigrations(config: CoreConfig, options?: RunMigrationsOptions): Promise<void>;
|
|
8
|
+
|
|
9
|
+
declare class InsufficientCreditError extends Error {
|
|
10
|
+
readonly availableMicros: number;
|
|
11
|
+
readonly requiredMicros: number;
|
|
12
|
+
readonly statusCode = 402;
|
|
13
|
+
readonly code = "PAYMENT_REQUIRED";
|
|
14
|
+
constructor(availableMicros: number, requiredMicros: number);
|
|
15
|
+
}
|
|
16
|
+
interface MeteringBalance {
|
|
17
|
+
userId: string;
|
|
18
|
+
grantedMicros: number;
|
|
19
|
+
usedMicros: number;
|
|
20
|
+
remainingMicros: number;
|
|
21
|
+
activeReservedMicros: number;
|
|
22
|
+
/** remainingMicros minus activeReservedMicros; what a new reservation sees. */
|
|
23
|
+
availableMicros: number;
|
|
24
|
+
}
|
|
25
|
+
type CreditLedgerKind = 'grant' | 'purchase' | 'usage' | 'refund' | 'fallback';
|
|
26
|
+
/** One normalized, display-safe credit-ledger row for the account activity view.
|
|
27
|
+
* `amountMicros` is SIGNED: positive = credits added (grant/purchase), negative =
|
|
28
|
+
* consumed/removed (usage/refund). `description` is generic — no prompt/repo/model/
|
|
29
|
+
* provider/order/session details. */
|
|
30
|
+
interface CreditLedgerEntry {
|
|
31
|
+
id: string;
|
|
32
|
+
kind: CreditLedgerKind;
|
|
33
|
+
amountMicros: number;
|
|
34
|
+
createdAt: string;
|
|
35
|
+
description: string;
|
|
36
|
+
}
|
|
37
|
+
interface GrantOnceInput {
|
|
38
|
+
userId: string;
|
|
39
|
+
/** Unique per (userId, reason); repeat calls with the same reason are no-ops. */
|
|
40
|
+
reason: string;
|
|
41
|
+
amountMicros: number;
|
|
42
|
+
expiresAt?: Date;
|
|
43
|
+
}
|
|
44
|
+
interface ReserveInput {
|
|
45
|
+
userId: string;
|
|
46
|
+
workspaceId?: string;
|
|
47
|
+
sessionId?: string;
|
|
48
|
+
/** Stable run id; at most one active reservation may exist per runId. */
|
|
49
|
+
runId: string;
|
|
50
|
+
source?: string;
|
|
51
|
+
amountMicros: number;
|
|
52
|
+
ttlSeconds: number;
|
|
53
|
+
/**
|
|
54
|
+
* Reject when the user's available balance (remaining minus active
|
|
55
|
+
* reservations) is below this floor. Defaults to amountMicros, i.e. the
|
|
56
|
+
* reservation must fit entirely.
|
|
57
|
+
*/
|
|
58
|
+
minAvailableMicros?: number;
|
|
59
|
+
}
|
|
60
|
+
interface ReserveResult {
|
|
61
|
+
reservationId: string;
|
|
62
|
+
}
|
|
63
|
+
interface RecordUsageInput {
|
|
64
|
+
/** Stable idempotency key; a second insert with the same id is a no-op. */
|
|
65
|
+
usageId: string;
|
|
66
|
+
userId: string;
|
|
67
|
+
workspaceId?: string;
|
|
68
|
+
sessionId?: string;
|
|
69
|
+
runId?: string;
|
|
70
|
+
messageId?: string;
|
|
71
|
+
source?: string;
|
|
72
|
+
provider?: string;
|
|
73
|
+
model?: string;
|
|
74
|
+
inputTokens?: number;
|
|
75
|
+
outputTokens?: number;
|
|
76
|
+
cacheReadTokens?: number;
|
|
77
|
+
cacheWriteTokens?: number;
|
|
78
|
+
/** Provider-reported cost, in micros, before host pricing policy. */
|
|
79
|
+
providerCostMicros?: number;
|
|
80
|
+
/** Host-priced cost actually charged against the balance. */
|
|
81
|
+
billedCostMicros: number;
|
|
82
|
+
stopReason?: string;
|
|
83
|
+
metadata?: Record<string, unknown>;
|
|
84
|
+
}
|
|
85
|
+
interface RecordUsageResult {
|
|
86
|
+
inserted: boolean;
|
|
87
|
+
}
|
|
88
|
+
type ReservationFinalStatus = 'settled' | 'released';
|
|
89
|
+
interface FinishReservationInput {
|
|
90
|
+
/** Preferred key: the id returned by reserve(). */
|
|
91
|
+
reservationId?: string;
|
|
92
|
+
/** Fallback key; pair with userId to scope across tenants. */
|
|
93
|
+
runId?: string;
|
|
94
|
+
userId?: string;
|
|
95
|
+
}
|
|
96
|
+
declare class PostgresMeteringStore {
|
|
97
|
+
private db;
|
|
98
|
+
constructor(db: PostgresJsDatabase);
|
|
99
|
+
/** Idempotently create a grant keyed by (userId, reason). */
|
|
100
|
+
grantOnce(input: GrantOnceInput): Promise<{
|
|
101
|
+
created: boolean;
|
|
102
|
+
}>;
|
|
103
|
+
/**
|
|
104
|
+
* Credit a purchase exactly once GLOBALLY per order id. The order id is the
|
|
105
|
+
* primary key, so a webhook retry or a delivery misrouted to a different user
|
|
106
|
+
* can never double-credit. A per-order advisory lock serializes this against
|
|
107
|
+
* revokePurchase so a refund that arrives BEFORE order_created (out-of-order
|
|
108
|
+
* delivery) leaves a 'refunded' tombstone that blocks this grant — the user
|
|
109
|
+
* never keeps credits for a refunded order. Returns `granted: false` when the
|
|
110
|
+
* order was already processed or has been refunded.
|
|
111
|
+
*/
|
|
112
|
+
grantPurchaseOnce(input: {
|
|
113
|
+
orderId: string;
|
|
114
|
+
userId: string;
|
|
115
|
+
amountMicros: number;
|
|
116
|
+
source?: string;
|
|
117
|
+
storeId?: string;
|
|
118
|
+
testMode?: boolean;
|
|
119
|
+
currency?: string;
|
|
120
|
+
variantId?: string;
|
|
121
|
+
}): Promise<{
|
|
122
|
+
granted: boolean;
|
|
123
|
+
}>;
|
|
124
|
+
/**
|
|
125
|
+
* Revoke a refunded/disputed purchase. Under the same per-order advisory lock
|
|
126
|
+
* as grantPurchaseOnce, supports repeated PARTIAL refunds:
|
|
127
|
+
* - `refundFraction` is the cumulative fraction of the order (by money) that
|
|
128
|
+
* has been refunded — i.e. LS `refunded_amount / total` (both tax-inclusive),
|
|
129
|
+
* which maps the refund onto the same basis as the credited amount. The
|
|
130
|
+
* revoked credits = round(creditedMicros × fraction), capped at credited.
|
|
131
|
+
* The method debits only the delta since the last refund. Omit (undefined)
|
|
132
|
+
* for a full refund of the entire credited amount.
|
|
133
|
+
* - granted order → debit the delta, track cumulative `refunded_micros`, and
|
|
134
|
+
* mark 'refunded' once fully revoked; returns revoked=true when a debit was
|
|
135
|
+
* posted.
|
|
136
|
+
* - not yet seen → write a 'refunded' tombstone so a later order_created
|
|
137
|
+
* cannot grant; returns revoked=false (nothing was credited yet).
|
|
138
|
+
* - already fully refunded / no new delta → no-op; returns revoked=false.
|
|
139
|
+
*/
|
|
140
|
+
revokePurchase(orderId: string, opts?: {
|
|
141
|
+
refundFraction?: number;
|
|
142
|
+
source?: string;
|
|
143
|
+
allowTombstone?: boolean;
|
|
144
|
+
expectedStoreId?: string;
|
|
145
|
+
expectedTestMode?: boolean;
|
|
146
|
+
expectedCurrency?: string;
|
|
147
|
+
}): Promise<{
|
|
148
|
+
revoked: boolean;
|
|
149
|
+
}>;
|
|
150
|
+
/**
|
|
151
|
+
* Insert a refund debit idempotently by id. If the id already exists (manual
|
|
152
|
+
* repair / corrupted retry), VERIFY the existing row is the same debit (user +
|
|
153
|
+
* amount) — throw on mismatch so a caller never records a refund the balance was
|
|
154
|
+
* not actually debited for. Returns true iff a new debit row was written.
|
|
155
|
+
*/
|
|
156
|
+
private insertVerifiedLedgerDebit;
|
|
157
|
+
/** Total billed micros already recorded for a run (for fallback top-up so a
|
|
158
|
+
* partial-success run isn't charged the hold ON TOP of its real usage). */
|
|
159
|
+
billedMicrosForRun(userId: string, runId: string): Promise<number>;
|
|
160
|
+
/** Total billed micros for a specific RESERVATION (run attempt). Preferred over
|
|
161
|
+
* billedMicrosForRun for fallback top-up: runId is reused on client-nonce
|
|
162
|
+
* replay, so summing by runId would count a prior attempt's billing and let a
|
|
163
|
+
* later reusing attempt settle free. */
|
|
164
|
+
billedMicrosForReservation(userId: string, reservationId: string): Promise<number>;
|
|
165
|
+
/** Durably record that an ACTIVE reservation must be charged the fallback hold if
|
|
166
|
+
* it expires, BEFORE attempting the actual fallback charge. Committed on its own so
|
|
167
|
+
* the intent survives a subsequent failed charge write — the expiry sweep then
|
|
168
|
+
* charges a marked reservation even with zero billed rows (no free started run on a
|
|
169
|
+
* brief finalization-time DB outage). Idempotent; a no-op on a non-active row. */
|
|
170
|
+
markReservationFallbackCharge(userId: string, reservationId: string): Promise<void>;
|
|
171
|
+
getBalance(userId: string, now?: Date): Promise<MeteringBalance>;
|
|
172
|
+
/** Most-recent credit ledger for the account activity view: grants/purchases
|
|
173
|
+
* (positive) merged with usage/refund/fallback debits (negative), newest first,
|
|
174
|
+
* scoped to the user and capped at `limit` (clamped 1..50). Descriptions are
|
|
175
|
+
* generic/sanitized; zero-amount usage rows (zero-token) are omitted as noise. */
|
|
176
|
+
listLedger(userId: string, limit: number): Promise<CreditLedgerEntry[]>;
|
|
177
|
+
/**
|
|
178
|
+
* Reserve credit for a run. Serialized per user (advisory transaction
|
|
179
|
+
* lock) so concurrent reservations cannot jointly overdraw. Idempotent per
|
|
180
|
+
* runId: re-reserving while a reservation is still active returns the
|
|
181
|
+
* existing reservation instead of double-holding. Throws
|
|
182
|
+
* InsufficientCreditError when the available balance is below
|
|
183
|
+
* minAvailableMicros (default: the reservation amount itself).
|
|
184
|
+
*/
|
|
185
|
+
reserve(input: ReserveInput, now?: Date): Promise<ReserveResult>;
|
|
186
|
+
/** Idempotent ledger insert; returns whether a new row was written. Serialized
|
|
187
|
+
* under the user's advisory lock so a positive debit is ordered with that user's
|
|
188
|
+
* reserve()/expiry: a debit that pushes the balance below the floor is visible to a
|
|
189
|
+
* concurrent reserve (which then waits and is refused), keeping the documented
|
|
190
|
+
* "overshoot → next run refused" boundary even for over-budget/misrouted runs. */
|
|
191
|
+
recordUsage(input: RecordUsageInput): Promise<RecordUsageResult>;
|
|
192
|
+
/**
|
|
193
|
+
* Finish a reservation. Settling also recovers reservations that expired
|
|
194
|
+
* before a delayed settlement retry, so charged usage never leaves a
|
|
195
|
+
* reservation dangling. Releasing only touches active rows. Idempotent:
|
|
196
|
+
* repeat calls are no-ops.
|
|
197
|
+
*
|
|
198
|
+
* A runId may have more than one row (an expired row plus a fresh active
|
|
199
|
+
* retry), so the runId fallback resolves to the single newest matching row
|
|
200
|
+
* — a settle never flips both the dead and the live reservation together.
|
|
201
|
+
*/
|
|
202
|
+
finishReservation(input: FinishReservationInput, status: ReservationFinalStatus): Promise<{
|
|
203
|
+
updated: boolean;
|
|
204
|
+
}>;
|
|
205
|
+
/** Expire stale active reservations without charging. Returns the count. */
|
|
206
|
+
expireStaleReservations(now?: Date): Promise<number>;
|
|
207
|
+
/**
|
|
208
|
+
* Expire one user's stale active reservations under the SINGLE charge-aware
|
|
209
|
+
* policy. CALLER MUST already hold pg_advisory_xact_lock(hashtext(userId)).
|
|
210
|
+
* A reservation that reached TTL without an explicit settle/release had a failed
|
|
211
|
+
* finalization: if it has POSITIVE billed usage (`billedTotal > 0`) OR carries the
|
|
212
|
+
* durable `charge_on_expire` marker, the run did chargeable work, so top it up to
|
|
213
|
+
* the hold (idempotent) rather than free it; a reservation with only zero-billed
|
|
214
|
+
* rows and no marker is a non-billable/pre-execution abandon and is freed (so a
|
|
215
|
+
* user who closed the tab isn't over-charged).
|
|
216
|
+
*/
|
|
217
|
+
private expireUserStaleReservations;
|
|
218
|
+
private computeBalance;
|
|
219
|
+
private sumGrants;
|
|
220
|
+
private sumUsage;
|
|
221
|
+
private sumActiveReservations;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export { type CreditLedgerEntry as C, type FinishReservationInput as F, type GrantOnceInput as G, InsufficientCreditError as I, type MeteringBalance as M, PostgresMeteringStore as P, type RecordUsageInput as R, type RecordUsageResult as a, type ReservationFinalStatus as b, type ReserveInput as c, type ReserveResult as d, type RunMigrationsOptions as e, runMigrations as r };
|