@ophelio/sdk 0.1.1 → 0.2.1

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
@@ -5,9 +5,7 @@ membership & entitlement API. Works on Node 20+, Cloudflare Workers, Deno and
5
5
  Bun — zero runtime dependencies, ESM + CJS, fully typed from the platform's
6
6
  OpenAPI spec.
7
7
 
8
- > **Status:** pre-1.0. Minor versions may contain breaking changes (most
9
- > notably, list-method return shapes will change when the API gains
10
- > pagination).
8
+ > **Status:** pre-1.0. Minor versions may contain breaking changes.
11
9
 
12
10
  ## Install
13
11
 
@@ -42,17 +40,6 @@ Pass your project API key (`o_live_…`); the project is implied by the key:
42
40
  new Ophelio({ apiKey: 'o_live_…' })
43
41
  ```
44
42
 
45
- For first-party / server-side session use, pass auth headers instead and
46
- optionally your own `fetch`:
47
-
48
- ```ts
49
- new Ophelio({
50
- baseUrl: 'https://api.your-deployment.example',
51
- headers: { cookie: cookieHeader },
52
- fetch: event.fetch, // e.g. SvelteKit
53
- })
54
- ```
55
-
56
43
  ## Errors
57
44
 
58
45
  Non-2xx responses throw a typed subclass of `OphelioError` mapped from the
@@ -79,10 +66,12 @@ GETs are retried automatically on network errors / 429 / 502 / 503 / 504
79
66
  (exponential backoff with jitter, `Retry-After` honoured; configure with
80
67
  `maxRetries`, default 2). Plain mutations are **never** retried.
81
68
 
82
- `entitlements.redeem` and `entitlements.recordUsage` are idempotency-keyed:
83
- the SDK generates a key per call and reuses it across its own retries, so
84
- they're safely retryable. If you retry at the application level (offline
85
- gate queues), pass your own key:
69
+ The idempotency-keyed mutations — `memberships.create`, `memberships.renew`,
70
+ `memberships.createTransaction`, `entitlements.redeem`, and
71
+ `entitlements.recordUsage` — each generate a key per call and reuse it across
72
+ the SDK's own retries, so they're safely retried on transient failures. If you
73
+ retry at the application level (offline gate queues, at-least-once job runners),
74
+ pass your own key so replays are recognised across processes:
86
75
 
87
76
  ```ts
88
77
  await ophelio.entitlements.redeem(
@@ -115,44 +104,76 @@ switch (event.type) {
115
104
  Unknown event types still verify and parse, so new platform events don't
116
105
  break older SDK versions.
117
106
 
118
- ## API surface
107
+ ## Pagination
119
108
 
120
- Everything reachable with an API key is covered:
109
+ The larger list endpoints are paginated. Their `list*` methods accept an
110
+ optional `{ page_size?, page_token? }` and resolve to a `Page<T>`:
121
111
 
122
- | Namespace | Methods |
123
- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
124
- | `admit` | `check({ card })` |
125
- | `members` | `sync({ since? })`, `get(id)`, `listEntitlements(id, { type? })`, `reissueCard(id, { note? })` |
126
- | `memberships` | `list()`, `get(id)`, `create(...)`, `update(id, ...)`, `cancel(id, ...)`, `uncancel(id)`, `renew(id, ...)`, `upgrade(id, ...)`, `downgrade(id, ...)`, `listMembers(id)`, `listScheduledChanges(id, { status? })`, `cancelScheduledChange(id, changeId)`, `listTransactions(id)`, `listTermTransactions(id, termId)`, `createTransaction(id, ...)` |
127
- | `customers` | `list()`, `get(id)`, `create(...)`, `update(id, ...)`, `delete(id)`, `listMemberships(id)` |
128
- | `entitlements` | `redeem(...)`, `recordUsage(...)`, `list()`, `get(id)`, `create(...)`, `update(id, ...)`, `delete(id)` |
129
- | `plans` | `list()`, `get(id)`, `create(...)`, `update(id, ...)`, `delete(id)`, `listBillingOptions(id)`, `createBillingOption(id, ...)`, `listEntitlements(id)`, `createEntitlement(id, ...)`, `listPlanLinks(id)`, `createPlanLink(id, ...)`, `listPricingGroupMappings(id)`, `createPricingGroupMapping(id, ...)` |
130
- | `billingOptions` | `list()`, `get(id)`, `update(id, ...)`, `delete(id)`, `listSalesChannels(id)`, `createSalesChannelLink(id, ...)` |
131
- | `memberRoles` | `list()`, `get(id)`, `create(...)`, `update(id, ...)`, `delete(id)` |
132
- | `pricingGroups` | `list()`, `get(id)`, `create(...)`, `update(id, ...)`, `delete(id)` |
133
- | `salesChannels` | `list()`, `get(id)`, `create(...)`, `update(id, ...)`, `delete(id)` |
134
- | `planEntitlements` | `get(id)`, `update(id, ...)`, `delete(id)` |
135
- | `planLinks` | `delete(id)` |
136
- | `planPricingGroupMappings` | `delete(id)` |
137
- | `billingOptionSalesChannels` | `delete(id)` |
138
- | `webhookSubscriptions` | `list()`, `get(id)`, `create(...)`, `update(id, ...)`, `delete(id)`, `listDeliveries(id)` |
112
+ ```ts
113
+ interface Page<T> {
114
+ items: T[]
115
+ next_page_token: string // '' on the last page
116
+ total_size: number // exact count across all pages
117
+ }
118
+ ```
139
119
 
140
- Every method accepts a trailing `options` argument: `{ signal?, headers? }`
141
- (plus `idempotencyKey?` on `redeem` / `recordUsage`).
120
+ Most of the time, let `paginate` walk the pages for you — pass a closure that
121
+ forwards the pagination params, capturing any ids, filters and options in it:
142
122
 
143
- Endpoints requiring a user session (API key management, organisation and
144
- project admin) are intentionally not in the SDK — API keys cannot call them.
123
+ ```ts
124
+ import { collect, paginate } from '@ophelio/sdk'
145
125
 
146
- ## Development (monorepo)
126
+ for await (const membership of paginate((page) =>
127
+ ophelio.memberships.list(page),
128
+ )) {
129
+ // …
130
+ }
147
131
 
148
- Types are generated from `packages/ophelio-api/openapi.json`:
132
+ // nested + filtered:
133
+ for await (const change of paginate((page) =>
134
+ ophelio.memberships.listScheduledChanges(id, { ...page, status: 'all' }),
135
+ )) {
136
+ // …
137
+ }
149
138
 
150
- ```sh
151
- npm run generate:sdk # repo root: regenerate spec + SDK types
152
- npm test -w packages/ophelio-sdk
139
+ // or drain everything into an array (loads the whole collection into memory):
140
+ const customers = await collect((page) => ophelio.customers.list(page))
153
141
  ```
154
142
 
155
- `src/generated/schema.d.ts` is a build artifact (git-ignored), regenerated
156
- from the committed spec on every `build`, `typecheck`, and `test`. The
157
- committed source of truth is `packages/ophelio-api/openapi.json`; CI fails
158
- if it drifts from the API controllers.
143
+ To page manually — e.g. to surface `total_size` or drive numbered pages — feed
144
+ `next_page_token` back in as `page_token` until it comes back empty.
145
+
146
+ `page_size` defaults to 50 and is capped at 250. Because pagination is
147
+ offset-based, a row can be seen twice or skipped across a page boundary if the
148
+ collection is written to while you iterate. Paginated methods are marked
149
+ `(params?)` in the table below; the bounded lists (config resources,
150
+ `listMembers`) return a plain array.
151
+
152
+ ## API surface
153
+
154
+ Everything reachable with an API key is covered:
155
+
156
+ | Namespace | Methods |
157
+ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
158
+ | `admit` | `check({ card })` |
159
+ | `members` | `sync({ since? })`, `get(id)`, `listEntitlements(id, { type? })`, `reissueCard(id, { note? })` |
160
+ | `memberships` | `list(params?)`, `get(id)`, `create(...)`, `update(id, ...)`, `cancel(id, ...)`, `uncancel(id)`, `renew(id, ...)`, `upgrade(id, ...)`, `downgrade(id, ...)`, `listMembers(id)`, `listScheduledChanges(id, params?)`, `cancelScheduledChange(id, changeId)`, `listTransactions(id, params?)`, `listTermTransactions(id, termId, params?)`, `createTransaction(id, ...)` |
161
+ | `customers` | `list(params?)`, `get(id)`, `create(...)`, `update(id, ...)`, `delete(id)`, `listMemberships(id, params?)` |
162
+ | `entitlements` | `redeem(...)`, `recordUsage(...)`, `list()`, `get(id)`, `create(...)`, `update(id, ...)`, `delete(id)` |
163
+ | `plans` | `list()`, `get(id)`, `create(...)`, `update(id, ...)`, `delete(id)`, `listBillingOptions(id)`, `createBillingOption(id, ...)`, `listEntitlements(id)`, `createEntitlement(id, ...)`, `listPlanLinks(id)`, `createPlanLink(id, ...)`, `listPricingGroupMappings(id)`, `createPricingGroupMapping(id, ...)` |
164
+ | `billingOptions` | `list()`, `get(id)`, `update(id, ...)`, `delete(id)`, `listSalesChannels(id)`, `createSalesChannelLink(id, ...)` |
165
+ | `memberRoles` | `list()`, `get(id)`, `create(...)`, `update(id, ...)`, `delete(id)` |
166
+ | `pricingGroups` | `list()`, `get(id)`, `create(...)`, `update(id, ...)`, `delete(id)` |
167
+ | `salesChannels` | `list()`, `get(id)`, `create(...)`, `update(id, ...)`, `delete(id)` |
168
+ | `planEntitlements` | `get(id)`, `update(id, ...)`, `delete(id)` |
169
+ | `planLinks` | `delete(id)` |
170
+ | `planPricingGroupMappings` | `delete(id)` |
171
+ | `billingOptionSalesChannels` | `delete(id)` |
172
+ | `webhookSubscriptions` | `list()`, `get(id)`, `create(...)`, `update(id, ...)`, `delete(id)`, `listDeliveries(id, params?)` |
173
+
174
+ Every method accepts a trailing `options` argument: `{ signal?, headers? }`
175
+ (plus `idempotencyKey?` on the idempotency-keyed mutations: `memberships.create`
176
+ / `renew` / `createTransaction` and `entitlements.redeem` / `recordUsage`).
177
+
178
+ Endpoints requiring a user session (API key management, organisation and
179
+ project admin) are intentionally not in the SDK — API keys cannot call them.
package/dist/index.cjs CHANGED
@@ -117,6 +117,15 @@ function sleep(ms) {
117
117
  const DEFAULT_BASE_URL = "https://api.ophel.io";
118
118
  const DEFAULT_TIMEOUT_MS = 3e4;
119
119
  const DEFAULT_MAX_RETRIES = 2;
120
+ /**
121
+ * Resolves the caller-supplied idempotency key to the value sent on the wire.
122
+ * Trims it and falls back to a generated key when it is absent, empty, or
123
+ * whitespace-only — a blank key must never leave an idempotency-keyed mutation
124
+ * retryable but unkeyed, or a retried write could duplicate.
125
+ */
126
+ function resolveIdempotencyKey(idempotencyKey) {
127
+ return idempotencyKey?.trim() || crypto.randomUUID();
128
+ }
120
129
  var Transport = class {
121
130
  apiKey;
122
131
  baseUrl;
@@ -216,6 +225,13 @@ var APIResource = class {
216
225
  this.transport = transport;
217
226
  }
218
227
  };
228
+ function pageQuery(params) {
229
+ if (!params) return void 0;
230
+ const query = {};
231
+ if (params.page_size !== void 0) query.page_size = String(params.page_size);
232
+ if (params.page_token !== void 0) query.page_token = params.page_token;
233
+ return query;
234
+ }
219
235
  //#endregion
220
236
  //#region src/resources/admit.ts
221
237
  var AdmitResource = class extends APIResource {
@@ -300,13 +316,19 @@ var BillingOptionsResource = class extends APIResource {
300
316
  //#endregion
301
317
  //#region src/resources/customers.ts
302
318
  var CustomersResource = class extends APIResource {
303
- async list(options) {
304
- return (await this.transport.request({
319
+ async list(params, options) {
320
+ const response = await this.transport.request({
305
321
  method: "GET",
306
322
  path: "/api/v1/customers",
323
+ query: pageQuery(params),
307
324
  retryable: true,
308
325
  options
309
- })).customers;
326
+ });
327
+ return {
328
+ items: response.customers,
329
+ next_page_token: response.next_page_token,
330
+ total_size: response.total_size
331
+ };
310
332
  }
311
333
  get(id, options) {
312
334
  return this.transport.request({
@@ -342,13 +364,19 @@ var CustomersResource = class extends APIResource {
342
364
  options
343
365
  });
344
366
  }
345
- async listMemberships(id, options) {
346
- return (await this.transport.request({
367
+ async listMemberships(id, params, options) {
368
+ const response = await this.transport.request({
347
369
  method: "GET",
348
370
  path: `/api/v1/customers/${encodeURIComponent(id)}/memberships`,
371
+ query: pageQuery(params),
349
372
  retryable: true,
350
373
  options
351
- })).memberships;
374
+ });
375
+ return {
376
+ items: response.memberships,
377
+ next_page_token: response.next_page_token,
378
+ total_size: response.total_size
379
+ };
352
380
  }
353
381
  };
354
382
  //#endregion
@@ -365,7 +393,7 @@ var EntitlementsResource = class extends APIResource {
365
393
  method: "POST",
366
394
  path: "/api/v1/entitlements/redeem",
367
395
  body: params,
368
- idempotencyKey: idempotencyKey ?? crypto.randomUUID(),
396
+ idempotencyKey: resolveIdempotencyKey(idempotencyKey),
369
397
  retryable: true,
370
398
  options: requestOptions
371
399
  });
@@ -380,7 +408,7 @@ var EntitlementsResource = class extends APIResource {
380
408
  method: "POST",
381
409
  path: "/api/v1/entitlements/usage",
382
410
  body: params,
383
- idempotencyKey: idempotencyKey ?? crypto.randomUUID(),
411
+ idempotencyKey: resolveIdempotencyKey(idempotencyKey),
384
412
  retryable: true,
385
413
  options: requestOptions
386
414
  })).usage;
@@ -521,13 +549,19 @@ var MembersResource = class extends APIResource {
521
549
  //#endregion
522
550
  //#region src/resources/memberships.ts
523
551
  var MembershipsResource = class extends APIResource {
524
- async list(options) {
525
- return (await this.transport.request({
552
+ async list(params, options) {
553
+ const response = await this.transport.request({
526
554
  method: "GET",
527
555
  path: "/api/v1/memberships",
556
+ query: pageQuery(params),
528
557
  retryable: true,
529
558
  options
530
- })).memberships;
559
+ });
560
+ return {
561
+ items: response.memberships,
562
+ next_page_token: response.next_page_token,
563
+ total_size: response.total_size
564
+ };
531
565
  }
532
566
  get(id, options) {
533
567
  return this.transport.request({
@@ -537,13 +571,22 @@ var MembershipsResource = class extends APIResource {
537
571
  options
538
572
  });
539
573
  }
540
- create(params, options) {
574
+ /**
575
+ * Create a membership atomically (record, members, first term, optional
576
+ * payment). Idempotent: the Idempotency-Key is generated once per call (and
577
+ * reused across the SDK's internal retries), so a retried create returns the
578
+ * original membership instead of creating a duplicate. Supply your own key to
579
+ * dedupe application-level retries across processes.
580
+ */
581
+ create(params, options = {}) {
582
+ const { idempotencyKey, ...requestOptions } = options;
541
583
  return this.transport.request({
542
584
  method: "POST",
543
585
  path: "/api/v1/memberships",
544
586
  body: params,
545
- retryable: false,
546
- options
587
+ idempotencyKey: resolveIdempotencyKey(idempotencyKey),
588
+ retryable: true,
589
+ options: requestOptions
547
590
  });
548
591
  }
549
592
  update(id, params, options) {
@@ -573,25 +616,36 @@ var MembershipsResource = class extends APIResource {
573
616
  options
574
617
  });
575
618
  }
576
- async listTransactions(id, options) {
577
- return (await this.transport.request({
619
+ async listTransactions(id, params, options) {
620
+ const response = await this.transport.request({
578
621
  method: "GET",
579
622
  path: `/api/v1/memberships/${encodeURIComponent(id)}/transactions`,
623
+ query: pageQuery(params),
580
624
  retryable: true,
581
625
  options
582
- })).transactions;
626
+ });
627
+ return {
628
+ items: response.transactions,
629
+ next_page_token: response.next_page_token,
630
+ total_size: response.total_size
631
+ };
583
632
  }
584
633
  /**
585
634
  * Report a payment outcome. Ophel.io never processes payments — your
586
635
  * payment provider does; this records the result and drives dunning.
636
+ * Idempotent: the Idempotency-Key is generated once per call (and reused
637
+ * across the SDK's internal retries), so a retried report records the
638
+ * transaction once and does not advance dunning twice.
587
639
  */
588
- createTransaction(id, params, options) {
640
+ createTransaction(id, params, options = {}) {
641
+ const { idempotencyKey, ...requestOptions } = options;
589
642
  return this.transport.request({
590
643
  method: "POST",
591
644
  path: `/api/v1/memberships/${encodeURIComponent(id)}/transactions`,
592
645
  body: params,
593
- retryable: false,
594
- options
646
+ idempotencyKey: resolveIdempotencyKey(idempotencyKey),
647
+ retryable: true,
648
+ options: requestOptions
595
649
  });
596
650
  }
597
651
  async listMembers(id, options) {
@@ -602,14 +656,21 @@ var MembershipsResource = class extends APIResource {
602
656
  options
603
657
  })).members;
604
658
  }
605
- /** Open the next term. Rejected (409) while cancelled or cancellation-pending. */
606
- renew(id, params, options) {
659
+ /**
660
+ * Open the next term. Rejected (409) while cancelled or cancellation-pending.
661
+ * Idempotent: the Idempotency-Key is generated once per call (and reused
662
+ * across the SDK's internal retries), so a retried renewal returns the
663
+ * original result instead of opening a second term.
664
+ */
665
+ renew(id, params, options = {}) {
666
+ const { idempotencyKey, ...requestOptions } = options;
607
667
  return this.transport.request({
608
668
  method: "POST",
609
669
  path: `/api/v1/memberships/${encodeURIComponent(id)}/renew`,
610
670
  body: params,
611
- retryable: false,
612
- options
671
+ idempotencyKey: resolveIdempotencyKey(idempotencyKey),
672
+ retryable: true,
673
+ options: requestOptions
613
674
  });
614
675
  }
615
676
  /**
@@ -636,13 +697,21 @@ var MembershipsResource = class extends APIResource {
636
697
  });
637
698
  }
638
699
  async listScheduledChanges(id, params, options) {
639
- return (await this.transport.request({
700
+ const response = await this.transport.request({
640
701
  method: "GET",
641
702
  path: `/api/v1/memberships/${encodeURIComponent(id)}/scheduled-changes`,
642
- query: params?.status ? { status: params.status } : void 0,
703
+ query: {
704
+ ...pageQuery(params),
705
+ ...params?.status ? { status: params.status } : {}
706
+ },
643
707
  retryable: true,
644
708
  options
645
- })).scheduled_changes;
709
+ });
710
+ return {
711
+ items: response.scheduled_changes,
712
+ next_page_token: response.next_page_token,
713
+ total_size: response.total_size
714
+ };
646
715
  }
647
716
  async cancelScheduledChange(id, changeId, options) {
648
717
  await this.transport.request({
@@ -652,13 +721,19 @@ var MembershipsResource = class extends APIResource {
652
721
  options
653
722
  });
654
723
  }
655
- async listTermTransactions(id, termId, options) {
656
- return (await this.transport.request({
724
+ async listTermTransactions(id, termId, params, options) {
725
+ const response = await this.transport.request({
657
726
  method: "GET",
658
727
  path: `/api/v1/memberships/${encodeURIComponent(id)}/terms/${encodeURIComponent(termId)}/transactions`,
728
+ query: pageQuery(params),
659
729
  retryable: true,
660
730
  options
661
- })).transactions;
731
+ });
732
+ return {
733
+ items: response.transactions,
734
+ next_page_token: response.next_page_token,
735
+ total_size: response.total_size
736
+ };
662
737
  }
663
738
  };
664
739
  //#endregion
@@ -942,13 +1017,19 @@ var WebhookSubscriptionsResource = class extends APIResource {
942
1017
  options
943
1018
  });
944
1019
  }
945
- async listDeliveries(id, options) {
946
- return (await this.transport.request({
1020
+ async listDeliveries(id, params, options) {
1021
+ const response = await this.transport.request({
947
1022
  method: "GET",
948
1023
  path: `/api/v1/webhook-subscriptions/${encodeURIComponent(id)}/deliveries`,
1024
+ query: pageQuery(params),
949
1025
  retryable: true,
950
1026
  options
951
- })).webhook_deliveries;
1027
+ });
1028
+ return {
1029
+ items: response.webhook_deliveries,
1030
+ next_page_token: response.next_page_token,
1031
+ total_size: response.total_size
1032
+ };
952
1033
  }
953
1034
  /** The HMAC signing secret is only returned on creation — store it then. */
954
1035
  create(params, options) {
@@ -1072,6 +1153,52 @@ var Ophelio = class Ophelio {
1072
1153
  this.webhookSubscriptions = new WebhookSubscriptionsResource(transport);
1073
1154
  }
1074
1155
  };
1156
+ //#endregion
1157
+ //#region src/core/pagination.ts
1158
+ /**
1159
+ * Streams every item across all pages of a paginated `list*` method, fetching
1160
+ * the next page lazily as you iterate:
1161
+ *
1162
+ * ```ts
1163
+ * for await (const membership of paginate((page) => ophelio.memberships.list(page))) {
1164
+ * // …
1165
+ * }
1166
+ *
1167
+ * // nested + filtered + abortable:
1168
+ * for await (const change of paginate((page) =>
1169
+ * ophelio.memberships.listScheduledChanges(id, { ...page, status: 'all' }, { signal }))) {
1170
+ * // …
1171
+ * }
1172
+ * ```
1173
+ *
1174
+ * Because this walks offset pages, a row may be seen twice or skipped across a
1175
+ * page boundary if the collection is written to concurrently.
1176
+ */
1177
+ async function* paginate(fetchPage, params = {}) {
1178
+ let pageToken = params.page_token;
1179
+ do {
1180
+ const page = await fetchPage({
1181
+ ...params,
1182
+ page_token: pageToken
1183
+ });
1184
+ yield* page.items;
1185
+ pageToken = page.next_page_token || void 0;
1186
+ } while (pageToken);
1187
+ }
1188
+ /**
1189
+ * Drains every page into a single array — the "give me everything" shortcut
1190
+ * over `paginate`. Loads the whole collection into memory, so prefer
1191
+ * `paginate` for large or unbounded lists.
1192
+ *
1193
+ * ```ts
1194
+ * const customers = await collect((page) => ophelio.customers.list(page))
1195
+ * ```
1196
+ */
1197
+ async function collect(fetchPage, params = {}) {
1198
+ const items = [];
1199
+ for await (const item of paginate(fetchPage, params)) items.push(item);
1200
+ return items;
1201
+ }
1075
1202
  /**
1076
1203
  * Canonical webhook event codes for the Ophel.io platform.
1077
1204
  *
@@ -1112,5 +1239,7 @@ exports.UnavailableError = UnavailableError;
1112
1239
  exports.UnimplementedError = UnimplementedError;
1113
1240
  exports.WEBHOOK_EVENT_CODES = WEBHOOK_EVENT_CODES;
1114
1241
  exports.WebhookSignatureVerificationError = WebhookSignatureVerificationError;
1242
+ exports.collect = collect;
1115
1243
  exports.constructEvent = constructEvent;
1244
+ exports.paginate = paginate;
1116
1245
  exports.verifyWebhookSignature = verifyWebhookSignature;