mbase-sdk 0.0.2
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/LICENSE +21 -0
- package/README.md +497 -0
- package/dist/index.cjs +714 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +717 -0
- package/dist/index.d.ts +717 -0
- package/dist/index.js +698 -0
- package/dist/index.js.map +1 -0
- package/package.json +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Meterbase
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
# @meterbase/sdk
|
|
2
|
+
|
|
3
|
+
TypeScript SDK for the [Meterbase](https://github.com/usemeterbase/engine)
|
|
4
|
+
engine. Works on Node 18+, browsers, and edge runtimes — it uses `fetch` and
|
|
5
|
+
nothing else.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @meterbase/sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
Two calls, in this order: **`check` → do the work → `track`.**
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Meterbase } from "@meterbase/sdk"
|
|
19
|
+
|
|
20
|
+
const meterbase = new Meterbase({ apiKey: process.env.METERBASE_API_KEY! })
|
|
21
|
+
|
|
22
|
+
const { allowed } = await meterbase.check({
|
|
23
|
+
customer_id: "acct_1", // the tenant's own id, not ours
|
|
24
|
+
meter_id: "ai_tokens", // the meter's key, not ours
|
|
25
|
+
quantity: 50_000, // what you are about to spend; defaults to 1
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
if (!allowed) throw new Error("Out of quota")
|
|
29
|
+
|
|
30
|
+
const completion = await generate(prompt)
|
|
31
|
+
|
|
32
|
+
await meterbase.track({
|
|
33
|
+
customer_id: "acct_1",
|
|
34
|
+
meter_id: "ai_tokens",
|
|
35
|
+
quantity: completion.usage.total_tokens,
|
|
36
|
+
})
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
An API key names its own workspace, so there is no workspace to pass.
|
|
40
|
+
|
|
41
|
+
`check` is the gate, and the only call that ever refuses. It never throws for a
|
|
42
|
+
denial — that is an answer, not a failure. Absence of entitlement is not
|
|
43
|
+
permission: a customer with no plan and no grant is denied.
|
|
44
|
+
|
|
45
|
+
`available` is the whole capacity — the plan's unused entitlement for the
|
|
46
|
+
current period plus every open grant — and is `null` under `no_cap`, where
|
|
47
|
+
capacity is not a number. It is one figure and not a breakdown: `check` answers
|
|
48
|
+
from a single cached integer, and itemising where the capacity came from would
|
|
49
|
+
cost it that. For the per-grant detail, read a customer's allowances.
|
|
50
|
+
|
|
51
|
+
### Recording usage
|
|
52
|
+
|
|
53
|
+
`track` counts work that already happened — the tokens were spent, the image
|
|
54
|
+
was generated — so it **never refuses on capacity**. A quantity beyond the
|
|
55
|
+
customer's capacity is recorded, drives `available` to 0, and the next `check`
|
|
56
|
+
says no. The answer is a receipt, with no verdict and nothing to branch on:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
const { event } = await meterbase.track({
|
|
60
|
+
customer_id: "acct_1",
|
|
61
|
+
meter_id: "ai_tokens",
|
|
62
|
+
quantity: 250_000,
|
|
63
|
+
idempotency_key: "req_9f3a", // generated when you omit it
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
event.replayed // false the first time, true for a retry of the same call
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**The engine dates the event, not you.** There is no `occurred_at` to send: an
|
|
70
|
+
event is stamped by the database clock the write runs under, which is what
|
|
71
|
+
chooses the period it counts against. `occurred_at` and `recorded_at` come back
|
|
72
|
+
on the receipt as the same instant, both on the wire so a reader of either
|
|
73
|
+
keeps working. Nothing can be dated into a period that has already closed, so a
|
|
74
|
+
bill drawn from a period is never moved by a report that arrives after it.
|
|
75
|
+
|
|
76
|
+
#### Idempotency
|
|
77
|
+
|
|
78
|
+
`customer_id` + `idempotency_key` identifies one logical event.
|
|
79
|
+
|
|
80
|
+
| The retry sends | The engine answers |
|
|
81
|
+
| -------------------------------- | --------------------------------------------------------- |
|
|
82
|
+
| the same key, meter and quantity | the original event with `replayed: true`, writing nothing |
|
|
83
|
+
| the same key, a different event | `409 idempotency_conflict`, naming the original |
|
|
84
|
+
|
|
85
|
+
The timestamp is deliberately not compared: every call is stamped with its own
|
|
86
|
+
clock, so a retry carries a later one, and refusing that would punish the
|
|
87
|
+
ordinary retry. The original's timestamp comes back unchanged.
|
|
88
|
+
|
|
89
|
+
**The SDK generates a key when you omit one, and reuses it across that call's
|
|
90
|
+
retries** — including its own, since `track` is the one `POST` it will replay.
|
|
91
|
+
Supply your own when the caller already has an id for the work — a job id, a
|
|
92
|
+
request id — so that a retry from further out than this SDK replays too. Keys
|
|
93
|
+
are retained 35 days; a retry after that records a second event, which is not a
|
|
94
|
+
case this API sets out to serve.
|
|
95
|
+
|
|
96
|
+
### Customers
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
const customer = await meterbase.customers.create({
|
|
100
|
+
external_id: "acct_1",
|
|
101
|
+
name: "Acme",
|
|
102
|
+
metadata: { tier: "pro" },
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const found = await meterbase.customers.retrieveByExternalId("acct_1") // or null
|
|
106
|
+
const { data } = await meterbase.customers.list({ include_deleted: true })
|
|
107
|
+
|
|
108
|
+
await meterbase.customers.update(customer.id, { name: "Acme Inc" })
|
|
109
|
+
await meterbase.customers.delete(customer.id) // soft delete, idempotent
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Reading one customer tells you their plan.** `retrieve` and
|
|
113
|
+
`retrieveByExternalId` carry `plan` — the one in force now — and
|
|
114
|
+
`pending_plan`, the change waiting to land, each `null` when there is none:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
const acme = await meterbase.customers.retrieve(customer.id)
|
|
118
|
+
|
|
119
|
+
acme.plan?.key // "pro"
|
|
120
|
+
acme.plan?.cycle // "monthly" — what their period is measured in
|
|
121
|
+
acme.plan?.anchor_at // what it is measured from
|
|
122
|
+
acme.pending_plan // null, or the plan they move to and when
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`plan.id` is the **plan's** id, not the assignment's: what you want from an
|
|
126
|
+
embed is which plan, and `customers.plan.retrieve` is where instructions are
|
|
127
|
+
identified. `null` means they hold no plan, which is a default deny — a
|
|
128
|
+
customer with no plan and no grant is denied.
|
|
129
|
+
|
|
130
|
+
`customers.list` stays lean and does not embed, along with `create`, `update`
|
|
131
|
+
and `delete`; the same read would run per row for an answer most callers of a
|
|
132
|
+
list do not want.
|
|
133
|
+
|
|
134
|
+
### Alert thresholds
|
|
135
|
+
|
|
136
|
+
`thresholds` is a list of whole percents — 1–1000, at most 32, and a mark over
|
|
137
|
+
100 is an overage alert — and it hangs off both meters and customers. A
|
|
138
|
+
customer's list beats the meter's, which beats the workspace's defaults.
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
await meterbase.meters.create({
|
|
142
|
+
key: "ai_tokens",
|
|
143
|
+
name: "Tokens",
|
|
144
|
+
thresholds: [80, 100],
|
|
145
|
+
})
|
|
146
|
+
await meterbase.customers.update(customer.id, { thresholds: [50, 90] }) // this customer only
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
It is the one field where `null` is a value rather than silence:
|
|
150
|
+
|
|
151
|
+
| You send | It means |
|
|
152
|
+
| ------------------ | ------------------------------------------------- |
|
|
153
|
+
| nothing | keep the stored list |
|
|
154
|
+
| `thresholds: null` | inherit again — the meter's, then the workspace's |
|
|
155
|
+
| `thresholds: []` | alerts off for this scope alone |
|
|
156
|
+
| `thresholds: [80]` | these marks |
|
|
157
|
+
|
|
158
|
+
Crossings are recorded by the engine; no webhook delivers them yet.
|
|
159
|
+
|
|
160
|
+
### Meters
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
await meterbase.meters.create({ key: "api_calls", name: "API calls" }) // plus optional thresholds
|
|
164
|
+
const { data } = await meterbase.meters.list()
|
|
165
|
+
await meterbase.meters.archive(meterId) // soft delete, idempotent
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Plans
|
|
169
|
+
|
|
170
|
+
A plan has one billing period, fixed when the plan is created. Everything it
|
|
171
|
+
meters resets on that period, measured from each customer's own anchor — which
|
|
172
|
+
is why two customers on the same monthly plan renew on different days.
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
const plan = await meterbase.plans.create({
|
|
176
|
+
key: "pro",
|
|
177
|
+
name: "Pro",
|
|
178
|
+
cycle: "monthly", // daily · weekly · monthly · quarterly · half_yearly · yearly
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
await meterbase.plans.update(plan.id, { name: "Pro Plus" })
|
|
182
|
+
await meterbase.plans.archive(plan.id) // stops new assignments, keeps existing ones
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`cycle` is not patchable. Changing it would re-date every period the plan's
|
|
186
|
+
customers were already measured against, so a different cadence is a different
|
|
187
|
+
plan — move a customer with `customers.plan.assign`.
|
|
188
|
+
|
|
189
|
+
### Plan allowances
|
|
190
|
+
|
|
191
|
+
What a plan grants, one meter at a time. An allowance says _how much_, never
|
|
192
|
+
_how often_: the period is the plan's cycle. There is no update and no delete —
|
|
193
|
+
an edit appends the next version, so which amount applied when stays derivable.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
// The recurring cap, refreshed every period.
|
|
197
|
+
await meterbase.plans.allowances.set(plan.id, {
|
|
198
|
+
meter_id: meter.id,
|
|
199
|
+
amount: 1_000_000, // or no_cap: true
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// A signup bonus beside it, minted once when the plan is assigned.
|
|
203
|
+
await meterbase.plans.allowances.set(plan.id, {
|
|
204
|
+
meter_id: meter.id,
|
|
205
|
+
amount: 500,
|
|
206
|
+
kind: "one_time",
|
|
207
|
+
valid_for: { months: 3, days: 0 },
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const { data } = await meterbase.plans.allowances.list(plan.id)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Each `(plan, meter)` pair carries up to two lineages, `recurring` and
|
|
214
|
+
`one_time`, numbering themselves separately. `kind` defaults to `recurring`.
|
|
215
|
+
|
|
216
|
+
### A customer's plan
|
|
217
|
+
|
|
218
|
+
A customer's plan is an append-only history, not a field: assigning is the only
|
|
219
|
+
write, and it serves both a first plan and every later change. What differs
|
|
220
|
+
between the changes is **when** the new plan starts and **what happens to the
|
|
221
|
+
period the change lands in** — so each of those is its own call.
|
|
222
|
+
|
|
223
|
+
#### Putting them on a plan
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
await meterbase.customers.plan.assign(customer.id, { plan_id: plan.id })
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
A first assignment is always recorded as `immediate`: there is no period to
|
|
230
|
+
wait out. It is the one call that takes `cycle_anchor`, which puts their
|
|
231
|
+
billing periods on a date they already have — migrating them from another
|
|
232
|
+
system, say. Their renewal day is fixed from here and does not move again
|
|
233
|
+
unless you `restart` them.
|
|
234
|
+
|
|
235
|
+
#### Moving them to another plan
|
|
236
|
+
|
|
237
|
+
| Call | The new plan starts | This period's cap |
|
|
238
|
+
| ------------------------- | ------------------- | ------------------------------------------------ |
|
|
239
|
+
| `changeAtNextCycle` | at their renewal | untouched — they keep what they paid for |
|
|
240
|
+
| `changeNow` | now | the new plan's amount, for the whole period |
|
|
241
|
+
| `changeNow` + `"prorate"` | now | the two plans weighted by how long each was held |
|
|
242
|
+
| `restart` | now | a **fresh period** starts today |
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
// Upgrade them at their renewal. Nothing is split, so nothing is pro-rated.
|
|
246
|
+
await meterbase.customers.plan.changeAtNextCycle(customer.id, { plan: pro.id })
|
|
247
|
+
|
|
248
|
+
// Move them now. Usage already spent still counts, so a downgrade can deny
|
|
249
|
+
// until the period ends.
|
|
250
|
+
await meterbase.customers.plan.changeNow(customer.id, { plan: starter.id })
|
|
251
|
+
|
|
252
|
+
// Move them now and split the period fairly: 1,000/mo held for half a month
|
|
253
|
+
// then 400/mo for the other half is a cap of 700.
|
|
254
|
+
await meterbase.customers.plan.changeNow(customer.id, {
|
|
255
|
+
plan: starter.id,
|
|
256
|
+
reconciliation: "prorate",
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// Start over today: the running period closes where the change lands and
|
|
260
|
+
// keeps its usage, and the new plan's full amount opens at once.
|
|
261
|
+
await meterbase.customers.plan.restart(customer.id, { plan: pro.id })
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**`restart` is the one that moves their renewal day**, because it re-anchors
|
|
265
|
+
them to today. The other three leave it exactly where it was.
|
|
266
|
+
|
|
267
|
+
`change(customerId, { plan, effective, reconciliation })` is the same call with
|
|
268
|
+
both knobs in the open, for when they are chosen at runtime. `assign` is the
|
|
269
|
+
wire shape underneath all four, and still takes `plan_id`.
|
|
270
|
+
|
|
271
|
+
#### Two plans on different cycles
|
|
272
|
+
|
|
273
|
+
A weekly plan and a monthly one share no boundary to wait for and no period to
|
|
274
|
+
weight, so `changeAtNextCycle` and `changeNow` + `"prorate"` both answer
|
|
275
|
+
`CycleChangeRequiresResetError`. `restart` is the move that works, and plain
|
|
276
|
+
`changeNow` is the other way out — it keeps the period that is running.
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
import { CycleChangeRequiresResetError } from "@meterbase/sdk"
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
await meterbase.customers.plan.changeAtNextCycle(customer.id, {
|
|
283
|
+
plan: weekly.id,
|
|
284
|
+
})
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (error instanceof CycleChangeRequiresResetError) {
|
|
287
|
+
await meterbase.customers.plan.restart(customer.id, { plan: weekly.id })
|
|
288
|
+
} else throw error
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
#### Reading where they stand
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
const current = await meterbase.customers.plan.retrieve(customer.id) // or null
|
|
296
|
+
const scheduled = await meterbase.customers.plan.pending(customer.id) // or null
|
|
297
|
+
const { data } = await meterbase.customers.plan.history(customer.id)
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
`retrieve` is the plan **in force now**, which is not always the newest
|
|
301
|
+
instruction — a change dated ahead does not govern yet — and is `null` for a
|
|
302
|
+
customer holding no plan. That is a default deny rather than a failure, so it
|
|
303
|
+
is an answer here rather than a thrown `NotFoundError`; a customer who does not
|
|
304
|
+
exist at all still throws one.
|
|
305
|
+
|
|
306
|
+
`pending` is the change waiting to land, and at most one ever is: a newer
|
|
307
|
+
instruction supersedes the one before it. `history` is the whole trail,
|
|
308
|
+
superseded rows included, newest first — it is the audit log of what was asked
|
|
309
|
+
for, not only of what happened.
|
|
310
|
+
|
|
311
|
+
To call off a change that has not landed:
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
await meterbase.customers.plan.cancelScheduledChange(customer.id)
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Naming the plan a customer already holds is what supersedes a pending
|
|
318
|
+
instruction, and writes no new row of its own — so this reads the plan in force
|
|
319
|
+
and names it back. It returns the assignment still in force, or `null` for a
|
|
320
|
+
customer holding no plan, who can have nothing pending.
|
|
321
|
+
|
|
322
|
+
### A customer's allowances
|
|
323
|
+
|
|
324
|
+
Capacity on top of whatever the plan gives.
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
const grant = await meterbase.customers.allowances.grant(customer.id, {
|
|
328
|
+
meter_id: meter.id,
|
|
329
|
+
amount: 500,
|
|
330
|
+
source: "purchased", // or "bonus" · "manual"
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
const { data } = await meterbase.customers.allowances.list(customer.id, {
|
|
334
|
+
include_closed: true,
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
await meterbase.customers.allowances.revoke(customer.id, grant.id)
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Revoking withdraws what is left without erasing what was consumed, and is
|
|
341
|
+
idempotent. Grants the engine minted itself from a plan's `one_time` allowance
|
|
342
|
+
appear here with `source: "plan"`; they cannot be created through `grant`.
|
|
343
|
+
|
|
344
|
+
## Errors
|
|
345
|
+
|
|
346
|
+
Every failure is a `MeterbaseError`. Catch the specific one you care about, or
|
|
347
|
+
the base class for all of them.
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
import { ConflictError, RateLimitError } from "@meterbase/sdk"
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
await meterbase.customers.create({ external_id: "acct_1" })
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (error instanceof ConflictError) {
|
|
356
|
+
// error.code === "customer_external_id_taken"
|
|
357
|
+
}
|
|
358
|
+
if (error instanceof RateLimitError) {
|
|
359
|
+
// error.retryAfter, in seconds
|
|
360
|
+
}
|
|
361
|
+
throw error
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
| Class | When |
|
|
366
|
+
| ---------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
367
|
+
| `AuthenticationError` | 401 — unknown, revoked or malformed key |
|
|
368
|
+
| `PermissionDeniedError` | 403 — the key may not use this route |
|
|
369
|
+
| `NotFoundError` | 404 |
|
|
370
|
+
| `ConflictError` | 409 — a key or external id is taken |
|
|
371
|
+
| `IdempotencyConflictError` | 409 — a `ConflictError` naming the event that key already recorded, on `existingEventId` |
|
|
372
|
+
| `InvalidRequestError` | 422 — a field failed validation |
|
|
373
|
+
| `CycleChangeRequiresResetError` | 422 — an `InvalidRequestError`: the two plans' cycles differ, so use `restart` |
|
|
374
|
+
| `RateLimitError` | 429 |
|
|
375
|
+
| `ServerError` | 5xx |
|
|
376
|
+
| `ConnectionError` / `TimeoutError` | the request never got an answer |
|
|
377
|
+
|
|
378
|
+
`APIError` carries `status`, `code` and the parsed `body`. Branch on `code`,
|
|
379
|
+
which is stable; `message` is for humans and may change.
|
|
380
|
+
|
|
381
|
+
## Options
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
new Meterbase({
|
|
385
|
+
apiKey: "mb_sk_live_…",
|
|
386
|
+
baseUrl: "https://api.meterbase.dev",
|
|
387
|
+
timeout: 10_000, // per attempt
|
|
388
|
+
maxRetries: 2,
|
|
389
|
+
fetch: customFetch,
|
|
390
|
+
})
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Per-call overrides, including cancellation:
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
await meterbase.meters.list({}, { signal: controller.signal, timeout: 2_000 })
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
**Retries** apply to `GET`, `DELETE` and `track`, on connection failures, 408,
|
|
400
|
+
429 and 5xx, with exponential backoff and full jitter. `Retry-After` is honoured
|
|
401
|
+
when the engine sends it. Every other `POST` and `PATCH` is never replayed,
|
|
402
|
+
because a retried create could produce a second row.
|
|
403
|
+
|
|
404
|
+
`track` is the exception because it carries an idempotency key, and because the
|
|
405
|
+
SDK fixes that key **before** the retry loop starts: every attempt of one call
|
|
406
|
+
names the same logical event, so the engine replays it instead of counting the
|
|
407
|
+
work twice. If you retry `track` yourself, pass your own `idempotency_key` and
|
|
408
|
+
keep it the same across attempts for exactly that reason.
|
|
409
|
+
|
|
410
|
+
## Types
|
|
411
|
+
|
|
412
|
+
Types mirror the wire exactly, `snake_case` included, so this SDK reads the
|
|
413
|
+
same as the API reference and cannot drift from it through a translation layer.
|
|
414
|
+
|
|
415
|
+
## Versioning
|
|
416
|
+
|
|
417
|
+
**0.3.0 is a breaking change**, following the engine's own.
|
|
418
|
+
|
|
419
|
+
- `track` no longer takes `occurred_at`. The engine dates every event by the
|
|
420
|
+
database clock the write runs under, and refuses the field outright. The
|
|
421
|
+
receipt still carries `occurred_at` and `recorded_at`, now the same instant.
|
|
422
|
+
`occurred_at_out_of_window` no longer exists.
|
|
423
|
+
- `Reconciliation` is `none | prorate | reset`. `rebalance` is gone from what
|
|
424
|
+
you can send; it survives on historical assignment rows, which read as
|
|
425
|
+
`RecordedReconciliation`. `reconciliation_already_applied` and
|
|
426
|
+
`reconciliation_unsupported` no longer exist.
|
|
427
|
+
- Plan allowances have no `reconciliation`, on the way in or out.
|
|
428
|
+
- `customers.plan.retrieve` returns `Assignment | null` rather than throwing a
|
|
429
|
+
`NotFoundError` for a customer holding no plan.
|
|
430
|
+
- `customers.retrieve` and `retrieveByExternalId` return a `CustomerWithPlan`:
|
|
431
|
+
the same customer with `plan` and `pending_plan` embedded. `customers.list`,
|
|
432
|
+
`create`, `update` and `delete` still answer a plain `Customer`.
|
|
433
|
+
|
|
434
|
+
Additive in the same release: the plan-change helpers — `change`, `changeNow`,
|
|
435
|
+
`changeAtNextCycle`, `restart`, `cancelScheduledChange` and `pending` — and
|
|
436
|
+
`CycleChangeRequiresResetError`.
|
|
437
|
+
|
|
438
|
+
**0.2.0 was a breaking change.**
|
|
439
|
+
`CheckResult` lost `remaining` and `breakdown`
|
|
440
|
+
and gained `available: number | null`, following the engine's `check`, which
|
|
441
|
+
now answers from one cached integer and returns a single figure. Read
|
|
442
|
+
`available`, and take `null` under `no_cap` as "capacity is not a number here"
|
|
443
|
+
rather than as a 0. The per-grant detail moved to
|
|
444
|
+
`customers.allowances.list()`.
|
|
445
|
+
|
|
446
|
+
Additive in the same release: `thresholds` on `Meter` and `Customer`, and on
|
|
447
|
+
their create and update params.
|
|
448
|
+
|
|
449
|
+
Pre-1.0, the minor version is where breaking changes land — deliberately, so a
|
|
450
|
+
change that will not compile against your code cannot arrive as a patch.
|
|
451
|
+
|
|
452
|
+
## Development
|
|
453
|
+
|
|
454
|
+
```bash
|
|
455
|
+
npm install
|
|
456
|
+
npm test # vitest, against a local test double
|
|
457
|
+
npm run check # typecheck, lint, format, test, build, publint, attw
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### Two layers of tests
|
|
461
|
+
|
|
462
|
+
**`npm test`** runs against a real `node:http` server on an ephemeral port
|
|
463
|
+
rather than a stubbed `fetch`, so request building, headers, status handling
|
|
464
|
+
and the retry loop are exercised for real. It can make the server drop a
|
|
465
|
+
socket or stall, which is the only way to test the retry and timeout paths.
|
|
466
|
+
|
|
467
|
+
What it cannot check is whether those scripted responses match the engine: a
|
|
468
|
+
route that does not exist, a renamed field or a changed error code all pass
|
|
469
|
+
here and fail in production.
|
|
470
|
+
|
|
471
|
+
**`npm run test:contract`** closes that gap by running against a live engine.
|
|
472
|
+
|
|
473
|
+
```bash
|
|
474
|
+
cp .env.example .env # then fill in a key, or export the two variables
|
|
475
|
+
METERBASE_BASE_URL=http://localhost:8080 \
|
|
476
|
+
METERBASE_API_KEY=mb_sk_live_… \
|
|
477
|
+
npm run test:contract
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
It asserts the whole lifecycle of a customer, a meter and a plan; that a plan's
|
|
481
|
+
allowances become real entitlement a `check` can see, with the recurring cap
|
|
482
|
+
and the minted bonus summed into `available`; that tracked usage takes exactly
|
|
483
|
+
that much away, that a replay takes nothing, and that a reused key naming a
|
|
484
|
+
different event is refused; that a scheduled plan change is dated ahead and
|
|
485
|
+
reads as `pending`, that cancelling it supersedes it, that a `restart`
|
|
486
|
+
re-anchors the customer, and that two plans on different cycles refuse a
|
|
487
|
+
scheduled change; that soft deletes and archiving behave as
|
|
488
|
+
documented; that duplicates really answer
|
|
489
|
+
`409 customer_external_id_taken` / `409 meter_key_taken`; and — via
|
|
490
|
+
`Record<keyof T, true>` field maps — that the engine's fields still match the
|
|
491
|
+
SDK's types exactly, in both directions.
|
|
492
|
+
|
|
493
|
+
> **It creates and deletes real data.** Point it at a test workspace. It only
|
|
494
|
+
> touches resources it created, tagging each with a per-run id, and cleans up
|
|
495
|
+
> afterwards; soft-deleted customers and archived meters and plans remain, as
|
|
496
|
+
> the engine intends. It is excluded from `npm test` on purpose — a mutating suite should
|
|
497
|
+
> only run because someone asked for it.
|