mbase-sdk 0.0.4 → 0.0.6

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
@@ -1,21 +1,22 @@
1
- # @meterbase/sdk
1
+ # mbase-sdk
2
2
 
3
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
4
+ engine. Works on Node 20+, browsers, and edge runtimes — it uses `fetch` and
5
5
  nothing else.
6
6
 
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- npm install @meterbase/sdk
10
+ npm install mbase-sdk
11
11
  ```
12
12
 
13
13
  ## Usage
14
14
 
15
- Two calls, in this order: **`check` → do the work → `track`.**
15
+ Two calls, in this order: **`check` → do the work → `track`** — or, for work
16
+ that must not be done twice, [`reserve`](#work-you-cannot-do-twice).
16
17
 
17
18
  ```ts
18
- import { Meterbase } from "@meterbase/sdk"
19
+ import { Meterbase } from "mbase-sdk"
19
20
 
20
21
  const meterbase = new Meterbase({ apiKey: process.env.METERBASE_API_KEY! })
21
22
 
@@ -96,12 +97,12 @@ it is within **one hour** of the engine's clock, and keeps the claim behind it
96
97
  until exactly that hour is up. A retry is therefore recognised for as long as
97
98
  it is accepted at all.
98
99
 
99
- | The call sends | The engine answers |
100
- | ------------------------------------- | ------------------------------------------------------ |
101
- | a fresh key | the recorded event, `replayed: false` |
102
- | a key it has already seen | that event with `replayed: true`, writing nothing |
103
- | a key more than an hour old, or ahead | `422 too_late`, writing nothing |
104
- | anything that is not a UUIDv7 | `422 invalid_idempotency_key`, writing nothing |
100
+ | The call sends | The engine answers |
101
+ | ------------------------------------- | ------------------------------------------------- |
102
+ | a fresh key | the recorded event, `replayed: false` |
103
+ | a key it has already seen | that event with `replayed: true`, writing nothing |
104
+ | a key more than an hour old, or ahead | `422 too_late`, writing nothing |
105
+ | anything that is not a UUIDv7 | `422 invalid_idempotency_key`, writing nothing |
105
106
 
106
107
  A key it has already seen replays **whatever the rest of the body says**:
107
108
  there is one claim behind the key and nothing to compare a retry against, so
@@ -128,6 +129,68 @@ request id — so that a retry from further out than this SDK replays too. Keys
128
129
  are retained 35 days; a retry after that records a second event, which is not a
129
130
  case this API sets out to serve.
130
131
 
132
+ ### Work you cannot do twice
133
+
134
+ `check` → work → `track` has a hole: two concurrent callers both pass the gate,
135
+ both do the work, and both record it. For an API call that costs nothing that
136
+ is an accepted trade. For an LLM completion, an image, a transcode, it is the
137
+ whole problem — the customer is over their cap and the money is already spent.
138
+
139
+ `reserve` closes it. It makes the same decision `check` makes and then **holds**
140
+ the capacity, so two reserves for the same last units cannot both be granted:
141
+
142
+ ```ts
143
+ const hold = await meterbase.reserve({
144
+ customer_id: "acct_1",
145
+ meter_id: "ai_tokens",
146
+ quantity: 50_000, // what you are about to spend; defaults to 1
147
+ expires_in_seconds: 60, // how long to hold it; defaults to 60, at most 900
148
+ })
149
+
150
+ if (!hold.allowed) throw new Error(`Refused: ${hold.reason}`)
151
+
152
+ try {
153
+ const completion = await generate(prompt)
154
+ await hold.commit(completion.usage.total_tokens) // records what it cost
155
+ } catch (error) {
156
+ await hold.release() // nothing happened, so nothing is recorded
157
+ throw error
158
+ }
159
+ ```
160
+
161
+ **The rule for choosing: can you afford to do the work twice? `check`. No?
162
+ `reserve`.** A reserve costs a database transaction where a check costs a cached
163
+ read — refusals included — so it is not the call to put in front of cheap work.
164
+
165
+ A reserve answers everything `check` answers, plus the hold: `reservation_id`,
166
+ the `quantity` it set aside, and `expires_at`. `available` is already net of it.
167
+ A refusal holds nothing, so it carries no id and no expiry, and `allowed`
168
+ narrows the type — after `if (!hold.allowed)`, `commit` and `release` are there.
169
+
170
+ `commit` is `track` under the hold's own id, which is what makes it a commit
171
+ rather than a second event beside it. Pass what the work actually cost and the
172
+ difference comes back; omit it to record what was held. Going over is allowed —
173
+ the hold only ever guaranteed what it reserved.
174
+
175
+ `release` gives the capacity back and records nothing. It is built for the
176
+ `catch` block, so it resolves with `null` instead of throwing when there is
177
+ nothing left to release — already committed, already released — because
178
+ throwing there would mask the error you are already handling.
179
+ `meterbase.release({ reservation_id })` throws that 404, as every other call
180
+ does.
181
+
182
+ **Nothing is lost if your process dies.** A hold expires on its own, and the
183
+ capacity comes back with nothing having run. A commit that arrives after
184
+ `expires_at` is still recorded — you lose the guarantee, not the event — and
185
+ the SDK warns when that happens, which is the only feedback there is on an
186
+ `expires_in_seconds` guessed too short.
187
+
188
+ `reservation_id` is a UUIDv7 on the same terms as `idempotency_key`, generated
189
+ when you omit it, and it becomes the event's id once the hold is committed. One
190
+ id names the hold, the claim and the event. An id is for one reserve and its
191
+ retries: once its hold is committed or released, sending it again reserves
192
+ afresh rather than finding anything.
193
+
131
194
  ### Customers
132
195
 
133
196
  ```ts
@@ -311,7 +374,7 @@ weight, so `changeAtNextCycle` and `changeNow` + `"prorate"` both answer
311
374
  `changeNow` is the other way out — it keeps the period that is running.
312
375
 
313
376
  ```ts
314
- import { CycleChangeRequiresResetError } from "@meterbase/sdk"
377
+ import { CycleChangeRequiresResetError } from "mbase-sdk"
315
378
 
316
379
  try {
317
380
  await meterbase.customers.plan.changeAtNextCycle(customer.id, {
@@ -354,6 +417,42 @@ instruction, and writes no new row of its own — so this reads the plan in forc
354
417
  and names it back. It returns the assignment still in force, or `null` for a
355
418
  customer holding no plan, who can have nothing pending.
356
419
 
420
+ #### How much they have used
421
+
422
+ ```ts
423
+ const entitlement = await meterbase.customers.entitlement(customer.id) // or null
424
+
425
+ for (const meter of entitlement?.meters ?? []) {
426
+ console.log(meter.meter_id, `${meter.used} of ${meter.amount ?? "unlimited"}`)
427
+ }
428
+ ```
429
+
430
+ `entitlement` is the plan's side of the current period, one entry per meter the
431
+ plan meters: the read behind a usage bar in your own app. `used` is what
432
+ `amount` is measured against, `total` adds what grants paid for, and
433
+ `available` is what `check` would answer. Meters are named by id, and like
434
+ `retrieve` it is `null` for a customer holding no plan.
435
+
436
+ Unlike `check`, it is not answered from a cache, so read it where usage is
437
+ shown rather than on every request.
438
+
439
+ #### Usage by day
440
+
441
+ ```ts
442
+ const week = await meterbase.customers.usage(customer.id, { days: 7 })
443
+ const period = await meterbase.customers.usage(customer.id, {
444
+ period: "current",
445
+ }) // or null
446
+ const meter = await meterbase.meters.usage(meterId, { days: 30 })
447
+ ```
448
+
449
+ Usage per UTC day: `days` is 1–90 days ending today, and a customer can also
450
+ be read by `period`, `current` or `last`. Each meter lists every day that has
451
+ begun, zeros included; a customer's meters with no usage in the range are left
452
+ out. A period the customer never had is `null`.
453
+
454
+ Figures can be up to five minutes old. `check` and `entitlement` are live.
455
+
357
456
  ### A customer's allowances
358
457
 
359
458
  Capacity on top of whatever the plan gives.
@@ -382,7 +481,7 @@ Every failure is a `MeterbaseError`. Catch the specific one you care about, or
382
481
  the base class for all of them.
383
482
 
384
483
  ```ts
385
- import { ConflictError, RateLimitError } from "@meterbase/sdk"
484
+ import { ConflictError, RateLimitError } from "mbase-sdk"
386
485
 
387
486
  try {
388
487
  await meterbase.customers.create({ external_id: "acct_1" })
@@ -397,19 +496,19 @@ try {
397
496
  }
398
497
  ```
399
498
 
400
- | Class | When |
401
- | ---------------------------------- | ---------------------------------------------------------------------------------------- |
402
- | `AuthenticationError` | 401 — unknown, revoked or malformed key |
403
- | `PermissionDeniedError` | 403 — the key may not use this route |
404
- | `NotFoundError` | 404 |
405
- | `ConflictError` | 409 — a key or external id is taken |
406
- | `InvalidRequestError` | 422 — a field failed validation |
407
- | `CycleChangeRequiresResetError` | 422 — an `InvalidRequestError`: the two plans' cycles differ, so use `restart` |
408
- | `InvalidIdempotencyKeyError` | 422 — an `InvalidRequestError`: the key was not a UUIDv7 |
499
+ | Class | When |
500
+ | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
501
+ | `AuthenticationError` | 401 — unknown, revoked or malformed key |
502
+ | `PermissionDeniedError` | 403 — the key may not use this route |
503
+ | `NotFoundError` | 404 |
504
+ | `ConflictError` | 409 — a key or external id is taken |
505
+ | `InvalidRequestError` | 422 — a field failed validation |
506
+ | `CycleChangeRequiresResetError` | 422 — an `InvalidRequestError`: the two plans' cycles differ, so use `restart` |
507
+ | `InvalidIdempotencyKeyError` | 422 — an `InvalidRequestError`: the key was not a UUIDv7 |
409
508
  | `TooLateError` | 422 — an `InvalidRequestError`: the key is over an hour from the engine's clock, which it carries on `serverTime`. Nothing was recorded |
410
- | `RateLimitError` | 429 |
411
- | `ServerError` | 5xx |
412
- | `ConnectionError` / `TimeoutError` | the request never got an answer |
509
+ | `RateLimitError` | 429 |
510
+ | `ServerError` | 5xx |
511
+ | `ConnectionError` / `TimeoutError` | the request never got an answer |
413
512
 
414
513
  `APIError` carries `status`, `code` and the parsed `body`. Branch on `code`,
415
514
  which is stable; `message` is for humans and may change.
package/dist/index.cjs CHANGED
@@ -41,6 +41,8 @@ var TooLateError = class extends InvalidRequestError {
41
41
  this.serverTime = serverTime(args.body);
42
42
  }
43
43
  };
44
+ var ReservationMismatchError = class extends InvalidRequestError {
45
+ };
44
46
  var CycleChangeRequiresResetError = class extends InvalidRequestError {
45
47
  };
46
48
  var RateLimitError = class extends APIError {
@@ -73,6 +75,8 @@ function errorFromResponse(args) {
73
75
  return new CycleChangeRequiresResetError(args);
74
76
  case "invalid_idempotency_key":
75
77
  return new InvalidIdempotencyKeyError(args);
78
+ case "reservation_mismatch":
79
+ return new ReservationMismatchError(args);
76
80
  case "too_late":
77
81
  return new TooLateError(args);
78
82
  default:
@@ -133,7 +137,7 @@ var Client = class {
133
137
  this.#fetch = options.fetch ?? globalThis.fetch;
134
138
  if (typeof this.#fetch !== "function") {
135
139
  throw new MeterbaseError(
136
- "No fetch implementation: pass one as `fetch`, or run on Node 18+."
140
+ "No fetch implementation: pass one as `fetch`, or run on Node 20+."
137
141
  );
138
142
  }
139
143
  }
@@ -504,6 +508,36 @@ var Customers = class {
504
508
  });
505
509
  return data[0] ?? null;
506
510
  }
511
+ /** `null` for a customer holding no plan, as `plan.retrieve` answers. */
512
+ async entitlement(id, options) {
513
+ try {
514
+ return await this.#client.request({
515
+ ...options,
516
+ method: "GET",
517
+ path: `/v1/customers/${encodeURIComponent(id)}/entitlement`
518
+ });
519
+ } catch (error) {
520
+ if (error instanceof NotFoundError && error.code === "no_plan_assigned") {
521
+ return null;
522
+ }
523
+ throw error;
524
+ }
525
+ }
526
+ async usage(id, params, options) {
527
+ try {
528
+ return await this.#client.request({
529
+ ...options,
530
+ method: "GET",
531
+ path: `/v1/customers/${encodeURIComponent(id)}/usage`,
532
+ query: { days: params.days, period: params.period }
533
+ });
534
+ } catch (error) {
535
+ if (error instanceof NotFoundError && (error.code === "no_plan_assigned" || error.code === "no_previous_period")) {
536
+ return null;
537
+ }
538
+ throw error;
539
+ }
540
+ }
507
541
  update(id, params, options) {
508
542
  return this.#client.request({
509
543
  ...options,
@@ -559,6 +593,15 @@ var Meters = class {
559
593
  body: params
560
594
  });
561
595
  }
596
+ /** Every customer's usage of the meter per UTC day. */
597
+ usage(id, params, options) {
598
+ return this.#client.request({
599
+ ...options,
600
+ method: "GET",
601
+ path: `/v1/meters/${encodeURIComponent(id)}/usage`,
602
+ query: { days: params.days }
603
+ });
604
+ }
562
605
  /** Soft delete: plans and usage keep referencing the meter. Idempotent. */
563
606
  archive(id, options) {
564
607
  return this.#client.request({
@@ -693,18 +736,83 @@ var Meterbase = class {
693
736
  // `async` so that generating the key cannot throw synchronously out of a
694
737
  // method that otherwise only ever rejects: one call, one way to fail.
695
738
  async track(params, options) {
696
- const send = (key2) => this.#client.request({
739
+ return this.#keyed(
740
+ IDEMPOTENCY_WINDOW_MS,
741
+ params.idempotency_key,
742
+ (key) => this.#client.request({
743
+ ...options,
744
+ method: "POST",
745
+ path: "/v1/usage/track",
746
+ // Fixed before the retry loop is entered: every attempt of this call
747
+ // carries the same key, which is what makes replaying it safe.
748
+ body: { ...params, idempotency_key: key },
749
+ idempotent: true
750
+ })
751
+ );
752
+ }
753
+ /**
754
+ * Holds capacity for work that cannot be done twice, or refuses. The same
755
+ * decision `check` makes, and then a hold on the capacity until the work
756
+ * is committed, released, or expires.
757
+ *
758
+ * The rule for choosing: can you afford to do the work twice? `check`. No?
759
+ * `reserve`. A reserve costs a transaction where a check costs a cached
760
+ * read, refusals included, so it is not a per-request gate on cheap work.
761
+ *
762
+ * Two concurrent reserves for the same customer and meter cannot both be
763
+ * granted the same capacity; that is the whole point, and it is what
764
+ * `check` → work → `track` could never promise.
765
+ */
766
+ async reserve(params, options) {
767
+ const response = await this.#keyed(
768
+ RESERVE_WINDOW_MS,
769
+ params.reservation_id,
770
+ (id) => this.#client.request({
771
+ ...options,
772
+ method: "POST",
773
+ path: "/v1/usage/reserve",
774
+ body: { ...params, reservation_id: id },
775
+ // A retry under the same id finds the hold it already made rather
776
+ // than making a second one.
777
+ idempotent: true
778
+ })
779
+ );
780
+ return this.#hold(params, response);
781
+ }
782
+ /**
783
+ * Gives a hold back: the work did not happen, so nothing is recorded.
784
+ *
785
+ * Throws `NotFoundError` when the id names nothing — committed, released
786
+ * already, or never made, which are one absence with one meaning. Prefer
787
+ * `hold.release()`, which treats that as the success it is.
788
+ */
789
+ release(params, options) {
790
+ return this.#client.request({
697
791
  ...options,
698
792
  method: "POST",
699
- path: "/v1/usage/track",
700
- // Fixed before the retry loop is entered: every attempt of this call
701
- // carries the same key, which is what makes replaying it safe.
702
- body: { ...params, idempotency_key: key2 },
703
- idempotent: true
793
+ path: "/v1/usage/release",
794
+ body: params
704
795
  });
705
- if (params.idempotency_key !== void 0) {
706
- return send(params.idempotency_key);
707
- }
796
+ }
797
+ /** Reports which workspace this key acts for. */
798
+ whoami(options) {
799
+ return this.#client.request({
800
+ ...options,
801
+ method: "GET",
802
+ path: "/v1/whoami"
803
+ });
804
+ }
805
+ /**
806
+ * Sends a call that carries a caller-minted UUIDv7, minting one when the
807
+ * caller supplied none and re-minting once if this machine's clock put it
808
+ * outside the engine's window.
809
+ *
810
+ * A key the caller supplied is never re-minted: this cannot know whether it
811
+ * names work already recorded, and replacing it could count that work
812
+ * twice.
813
+ */
814
+ async #keyed(window, supplied, send) {
815
+ if (supplied !== void 0) return send(supplied);
708
816
  const key = idempotencyKey(this.#client.now());
709
817
  const startedAt = Date.now();
710
818
  try {
@@ -715,23 +823,59 @@ var Meterbase = class {
715
823
  }
716
824
  const elapsed = Date.now() - startedAt;
717
825
  const serverAtStart = error.serverTime.getTime() - elapsed;
718
- if (Math.abs(serverAtStart - mintedAtMs(key)) <= IDEMPOTENCY_WINDOW_MS) {
719
- throw error;
720
- }
826
+ if (Math.abs(serverAtStart - mintedAtMs(key)) <= window) throw error;
721
827
  this.#client.observeServerTime(error.serverTime);
722
828
  return await send(idempotencyKey(this.#client.now()));
723
829
  }
724
830
  }
725
- /** Reports which workspace this key acts for. */
726
- whoami(options) {
727
- return this.#client.request({
728
- ...options,
729
- method: "GET",
730
- path: "/v1/whoami"
731
- });
831
+ /** Puts `commit` and `release` on a granted hold; a refusal is left as it is. */
832
+ #hold(params, response) {
833
+ if (!response.allowed) return response;
834
+ const id = response.reservation_id;
835
+ const expiresAt = response.expires_at;
836
+ if (id === void 0 || expiresAt === void 0) {
837
+ throw new MeterbaseError(
838
+ "The engine granted a reservation without an id or an expiry, so there is no hold to commit or release. The capacity it set aside comes back on its own."
839
+ );
840
+ }
841
+ const expiresAtMs = Date.parse(expiresAt);
842
+ return {
843
+ ...response,
844
+ allowed: true,
845
+ reservation_id: id,
846
+ quantity: response.quantity ?? params.quantity ?? 1,
847
+ expires_at: expiresAt,
848
+ commit: (quantity, commitOptions) => {
849
+ if (Number.isFinite(expiresAtMs) && this.#client.now() > expiresAtMs && typeof globalThis.console?.warn === "function") {
850
+ globalThis.console.warn(
851
+ `Meterbase: committing reservation ${id} after it expired at ${expiresAt}. The event is still recorded, but the capacity was no longer held \u2014 raise expires_in_seconds for this work.`
852
+ );
853
+ }
854
+ return this.track(
855
+ {
856
+ customer_id: params.customer_id,
857
+ meter_id: params.meter_id,
858
+ quantity,
859
+ // The hold's own id, so the track commits it rather than
860
+ // recording a second event beside it.
861
+ idempotency_key: id
862
+ },
863
+ commitOptions
864
+ );
865
+ },
866
+ release: async (releaseOptions) => {
867
+ try {
868
+ return await this.release({ reservation_id: id }, releaseOptions);
869
+ } catch (error) {
870
+ if (error instanceof NotFoundError) return null;
871
+ throw error;
872
+ }
873
+ }
874
+ };
732
875
  }
733
876
  };
734
877
  var IDEMPOTENCY_WINDOW_MS = 60 * 60 * 1e3;
878
+ var RESERVE_WINDOW_MS = 30 * 60 * 1e3;
735
879
  function mintedAtMs(key) {
736
880
  return Number.parseInt(key.replace(/-/g, "").slice(0, 12), 16);
737
881
  }
@@ -776,6 +920,7 @@ exports.MeterbaseError = MeterbaseError;
776
920
  exports.NotFoundError = NotFoundError;
777
921
  exports.PermissionDeniedError = PermissionDeniedError;
778
922
  exports.RateLimitError = RateLimitError;
923
+ exports.ReservationMismatchError = ReservationMismatchError;
779
924
  exports.ServerError = ServerError;
780
925
  exports.TimeoutError = TimeoutError;
781
926
  exports.TooLateError = TooLateError;