@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 CHANGED
@@ -7,314 +7,63 @@
7
7
 
8
8
  </div>
9
9
 
10
- The foundation package for boring-ui apps: Postgres/Drizzle database schema, email/password auth (better-auth), config loader, Fastify HTTP app factory, and React frontend shell. Every child app imports core first.
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
- // Server — 4 lines to a full app
42
- import { createCoreApp, loadConfig } from "@hachej/boring-core/server"
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
- // Frontend — mount auth gate + workspace routing
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 { BoringApp } from "@hachej/boring-core/front"
154
- import { Route } from "react-router-dom"
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
- <BoringApp>
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
- | Import | Environment | What You Get |
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
- ## Configuration
54
+ Migrations live in `drizzle/`; run them with `drizzle-kit migrate` against
55
+ `drizzle.config.ts`.
181
56
 
182
- ### Environment Variables
57
+ ## Documentation
183
58
 
184
- | Variable | Required | Description |
185
- |----------|----------|-------------|
186
- | `DATABASE_URL` | Yes (prod) | Postgres connection string |
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 };