@clocknext/sdk 0.10.0 → 0.12.0

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/src/flusher.ts ADDED
@@ -0,0 +1,144 @@
1
+ import type { Transport } from "./http.js";
2
+ import { InMemoryQueue, type QueueStore, type QueuedSignal } from "./queue.js";
3
+ import type { ResolvedConfig } from "./types.js";
4
+
5
+ /**
6
+ * Background send engine for async-mode signals. Buffers wire-ready signals and
7
+ * flushes them when the buffer reaches `maxSize` OR every `maxIntervalMs`,
8
+ * whichever comes first — the Segment/Amberflo pattern. The Transport owns
9
+ * retries; `close()` drains and awaits in-flight sends so a serverless process
10
+ * can flush before it freezes.
11
+ */
12
+ export class Flusher {
13
+ private readonly queue: QueueStore = new InMemoryQueue();
14
+ private timer: ReturnType<typeof setTimeout> | null = null;
15
+ private draining: Promise<void> | null = null;
16
+ private closed = false;
17
+
18
+ constructor(
19
+ private readonly transport: Transport,
20
+ private readonly cfg: ResolvedConfig,
21
+ ) {}
22
+
23
+ /**
24
+ * Buffer one signal. Drops (with onDrop) when closed or the queue is full.
25
+ * Returns whether it was actually buffered, so `track()` can report a drop
26
+ * honestly instead of answering "buffered" for a signal it threw away.
27
+ */
28
+ enqueue(item: QueuedSignal): boolean {
29
+ if (this.closed) {
30
+ // Not `send_failed` — nothing was ever sent. Reporting a send failure for
31
+ // a signal that never left the buffer sends a caller looking for a
32
+ // network problem that does not exist.
33
+ this.cfg.onDrop?.(item.signal, "closed");
34
+ return false;
35
+ }
36
+ if (this.queue.size() >= this.cfg.batch.maxQueueSize) {
37
+ this.cfg.logger.warn?.("[clocknext] queue full — dropping signal");
38
+ this.cfg.onDrop?.(item.signal, "queue_full");
39
+ return false;
40
+ }
41
+ this.queue.push(item);
42
+ if (this.queue.size() >= this.cfg.batch.maxSize) void this.flush();
43
+ else this.arm();
44
+ return true;
45
+ }
46
+
47
+ /** Arm the interval timer if it isn't already running. */
48
+ private arm(): void {
49
+ if (this.timer || this.closed) return;
50
+ this.timer = setTimeout(() => {
51
+ this.timer = null;
52
+ void this.flush();
53
+ }, this.cfg.batch.maxIntervalMs);
54
+ // Don't keep the event loop alive just for the flush timer.
55
+ (this.timer as { unref?: () => void }).unref?.();
56
+ }
57
+
58
+ /** Cancel the interval timer if one is armed. */
59
+ private clearTimer(): void {
60
+ if (this.timer) {
61
+ clearTimeout(this.timer);
62
+ this.timer = null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Send everything buffered at the moment this resolves.
68
+ *
69
+ * Overlapping calls CHAIN onto the in-flight drain rather than sharing it.
70
+ * Sharing looks equivalent and is not: a drain that has already passed its
71
+ * final `size() > 0` check cannot see a signal enqueued a moment later, so
72
+ * handing that same promise back would resolve `flush()` — and therefore
73
+ * `close()` — with the buffer non-empty. Since `close()` has already set
74
+ * `closed`, `arm()` will not re-arm, and the timer it did arm is `unref`'d,
75
+ * so the process is free to exit and that signal is simply lost. Chaining
76
+ * guarantees at least one full drain begins after this call.
77
+ *
78
+ * Concurrency is unaffected: drains run one after another, and each one
79
+ * sends at most `maxConcurrency` signals at a time.
80
+ */
81
+ flush(): Promise<void> {
82
+ this.clearTimer();
83
+ // A prior drain that rejected must not poison the chain, hence the catch.
84
+ const prior = this.draining ?? Promise.resolve();
85
+ const tracked: Promise<void> = prior
86
+ .catch(() => {})
87
+ .then(() => this.drain())
88
+ .finally(() => {
89
+ if (this.draining === tracked) this.draining = null;
90
+ });
91
+ this.draining = tracked;
92
+ return tracked;
93
+ }
94
+
95
+ /** Drain the buffer in concurrency-bounded waves until it's empty. */
96
+ private async drain(): Promise<void> {
97
+ let sent = 0;
98
+ while (this.queue.size() > 0) {
99
+ const wave = this.queue.take(this.cfg.batch.maxConcurrency);
100
+ const results = await Promise.all(wave.map((item) => this.send(item)));
101
+ sent += results.filter(Boolean).length;
102
+ }
103
+ if (sent > 0) this.cfg.onFlush?.(sent);
104
+ }
105
+
106
+ /** Send one item; never throws — failures surface via onError/onDrop. */
107
+ private async send(item: QueuedSignal): Promise<boolean> {
108
+ try {
109
+ await this.transport.request({
110
+ method: "POST",
111
+ // Per-item, not per-Flusher: one route per meter. See `QueuedSignal`.
112
+ path: item.path,
113
+ body: item.body,
114
+ // Best-effort metering opts into retries on transient failures.
115
+ retry: true,
116
+ // Rides onto any thrown error so `onError` can replay this exact
117
+ // signal without minting a new key (which would bill it twice).
118
+ idempotencyKey:
119
+ typeof item.body.idempotencyKey === "string"
120
+ ? item.body.idempotencyKey
121
+ : undefined,
122
+ });
123
+ return true;
124
+ } catch (error) {
125
+ this.cfg.onError?.(error, item.signal);
126
+ this.cfg.onDrop?.(item.signal, "send_failed");
127
+ return false;
128
+ }
129
+ }
130
+
131
+ get size(): number {
132
+ return this.queue.size();
133
+ }
134
+
135
+ /** Flush remaining signals, then refuse further enqueues. */
136
+ async close(): Promise<void> {
137
+ this.closed = true;
138
+ await this.flush();
139
+ // Nothing can be enqueued now, so the buffer is empty for good and a timer
140
+ // armed just before close has nothing left to do. Clearing it lets a
141
+ // process exit immediately instead of waiting on a stray handle.
142
+ this.clearTimer();
143
+ }
144
+ }
package/src/http.ts ADDED
@@ -0,0 +1,178 @@
1
+ import { NetworkError, RateLimitError, errorFromResponse } from "./errors.js";
2
+ import { computeBackoff, isRetryable, parseRetryAfter, sleep } from "./retry.js";
3
+ import type { ResolvedConfig } from "./types.js";
4
+
5
+ /**
6
+ * Injected from package.json at build time (see tsup `define`) so it can't
7
+ * drift. Falls back to a dev marker when run un-bundled (tests, ts-node).
8
+ */
9
+ declare const __SDK_VERSION__: string | undefined;
10
+ const SDK_VERSION =
11
+ typeof __SDK_VERSION__ !== "undefined" ? __SDK_VERSION__ : "0.0.0-dev";
12
+
13
+ interface RequestOptions {
14
+ method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
15
+ path: string;
16
+ query?: Record<string, string | number | undefined>;
17
+ body?: unknown;
18
+ /**
19
+ * Whether this call may be auto-retried on a transient failure. Defaults to
20
+ * method-based safety: reads (GET), idempotent replaces (PUT), and DELETE are
21
+ * replay-safe. Non-idempotent writes (POST/PATCH) default to NOT retried so a
22
+ * transient failure can't duplicate the write; callers that tolerate a retry
23
+ * opt in explicitly.
24
+ */
25
+ retry?: boolean;
26
+ /**
27
+ * The idempotency key carried in this request's body, for metering calls.
28
+ *
29
+ * Copied onto any error thrown, so a caller handling a permanent failure can
30
+ * replay the signal with the SAME key instead of minting a fresh one — which
31
+ * would defeat server-side dedup and bill twice. See
32
+ * `ClockNextError.idempotencyKey`.
33
+ */
34
+ idempotencyKey?: string;
35
+ }
36
+
37
+ /**
38
+ * The single HTTP seam. Builds the authenticated request, enforces a timeout,
39
+ * unwraps the `{ ok, ... }` envelope, maps failures to typed errors, and retries
40
+ * transient ones with backoff + jitter.
41
+ */
42
+ export class Transport {
43
+ constructor(private readonly cfg: ResolvedConfig) {}
44
+
45
+ async request<T = unknown>(opts: RequestOptions): Promise<T> {
46
+ const url = this.buildUrl(opts.path, opts.query);
47
+ // Reads, idempotent PUT replaces, and DELETE are replay-safe by default;
48
+ // non-idempotent writes are retried only when the caller opts in (e.g.
49
+ // best-effort metering signals).
50
+ const replaySafe =
51
+ opts.method === "GET" ||
52
+ opts.method === "PUT" ||
53
+ opts.method === "DELETE";
54
+ const allowRetry = opts.retry ?? replaySafe;
55
+ const maxAttempts = allowRetry ? this.cfg.retry.maxAttempts : 1;
56
+
57
+ let attempt = 0;
58
+ // eslint-disable-next-line no-constant-condition
59
+ while (true) {
60
+ attempt++;
61
+ try {
62
+ return await this.once<T>(url, opts);
63
+ } catch (error) {
64
+ const canRetry = attempt < maxAttempts && isRetryable(error);
65
+ if (!canRetry) throw error;
66
+ const retryAfterMs =
67
+ error instanceof RateLimitError ? error.retryAfterMs : undefined;
68
+ const delayMs = computeBackoff(attempt, this.cfg.retry, retryAfterMs);
69
+ this.cfg.onRetry?.({ attempt, delayMs, error });
70
+ this.cfg.logger.debug?.(
71
+ `[clocknext] retry ${attempt}/${maxAttempts - 1} in ${delayMs}ms`,
72
+ error instanceof Error ? error.message : error,
73
+ );
74
+ await sleep(delayMs);
75
+ }
76
+ }
77
+ }
78
+
79
+ /** One attempt: fetch + timeout + envelope/status handling. */
80
+ private async once<T>(url: string, opts: RequestOptions): Promise<T> {
81
+ const controller = new AbortController();
82
+ const timer = setTimeout(() => controller.abort(), this.cfg.timeoutMs);
83
+
84
+ let res: Response;
85
+ try {
86
+ res = await this.cfg.fetch(url, {
87
+ method: opts.method,
88
+ headers: this.headers(opts),
89
+ body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
90
+ signal: controller.signal,
91
+ });
92
+ } catch (err) {
93
+ const message =
94
+ controller.signal.aborted
95
+ ? `Request to ${opts.path} timed out after ${this.cfg.timeoutMs}ms.`
96
+ : `Network error calling ${opts.path}: ${
97
+ err instanceof Error ? err.message : String(err)
98
+ }`;
99
+ throw new NetworkError(message, { idempotencyKey: opts.idempotencyKey });
100
+ } finally {
101
+ clearTimeout(timer);
102
+ }
103
+
104
+ const text = await res.text();
105
+ let json: unknown = undefined;
106
+ if (text) {
107
+ try {
108
+ json = JSON.parse(text);
109
+ } catch {
110
+ // Non-JSON body — only an error if the status is also bad.
111
+ }
112
+ }
113
+
114
+ // Every `/api/v1` route answers through `v1Success`/`v1Error`:
115
+ // { statusCode, statusDetail: { status, message, code? }, result }
116
+ // The `{ ok, error, ...payload }` shape below is a defensive fallback for a
117
+ // route that has not been migrated (or a proxy that rewrites the body), not
118
+ // a contract the current API still speaks.
119
+ const obj = (json ?? {}) as Record<string, unknown>;
120
+ const meta = { idempotencyKey: opts.idempotencyKey };
121
+ const isEnveloped =
122
+ typeof obj === "object" && obj !== null && "statusDetail" in obj;
123
+
124
+ if (isEnveloped) {
125
+ const detail = obj.statusDetail as
126
+ | { status?: string; message?: string; code?: string }
127
+ | undefined;
128
+ if (res.ok && detail?.status === "SUCCESS") {
129
+ return ((obj.result as T | undefined) ?? ({} as T));
130
+ }
131
+ const message =
132
+ detail?.message ?? `Request failed with status ${res.status}.`;
133
+ throw errorFromResponse(res.status, message, parseRetryAfter(res.headers), {
134
+ ...meta,
135
+ code: detail?.code,
136
+ });
137
+ }
138
+
139
+ const envelope = obj as { ok?: boolean; error?: string };
140
+ if (res.ok && envelope.ok !== false) {
141
+ return envelope as T;
142
+ }
143
+ const message =
144
+ envelope.error ?? text ?? `Request failed with status ${res.status}.`;
145
+ throw errorFromResponse(
146
+ res.status,
147
+ message,
148
+ parseRetryAfter(res.headers),
149
+ meta,
150
+ );
151
+ }
152
+
153
+ private headers(opts: RequestOptions): Record<string, string> {
154
+ const h: Record<string, string> = {
155
+ Authorization: `Bearer ${this.cfg.apiKey}`,
156
+ "User-Agent": `clocknext-sdk-js/${SDK_VERSION}`,
157
+ };
158
+ if (opts.body !== undefined) h["Content-Type"] = "application/json";
159
+ return h;
160
+ }
161
+
162
+ private buildUrl(
163
+ path: string,
164
+ query?: Record<string, string | number | undefined>,
165
+ ): string {
166
+ // baseUrl already has trailing slashes stripped in resolveConfig.
167
+ let url = `${this.cfg.baseUrl}${path}`;
168
+ if (query) {
169
+ const params = new URLSearchParams();
170
+ for (const [k, v] of Object.entries(query)) {
171
+ if (v !== undefined && v !== "") params.set(k, String(v));
172
+ }
173
+ const qs = params.toString();
174
+ if (qs) url += `?${qs}`;
175
+ }
176
+ return url;
177
+ }
178
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Idempotency-key generation for usage signals.
3
+ *
4
+ * Every signal carries an idempotency key so that a retry — whether a
5
+ * Transport-level replay of one send, or a re-flush of the buffered body — is
6
+ * deduplicated on the server instead of recording a second, double-billed
7
+ * event. The server keys `RawUsageLog` on `(organizationId, idempotencyKey)`
8
+ * and replays the original result on a repeat.
9
+ *
10
+ * The key is generated ONCE, before the signal is buffered or sent, and the
11
+ * same key rides every retry of that signal (see ARCHITECTURE AD-4). A caller
12
+ * may instead supply their own key on the signal — a stable id for the logical
13
+ * event — to also dedup across process restarts / at-least-once redelivery,
14
+ * which an in-memory auto-generated key cannot survive.
15
+ */
16
+
17
+ /**
18
+ * A fresh idempotency key. A UUID v4 when the runtime exposes Web Crypto
19
+ * (Node ≥ 18, browsers, edge runtimes), with a non-cryptographic fallback for
20
+ * exotic runtimes that lack it. The value is opaque to the server — only its
21
+ * uniqueness matters, not its randomness quality.
22
+ */
23
+ export function newKey(): string {
24
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
25
+ if (typeof c?.randomUUID === "function") return c.randomUUID();
26
+ // Fallback: an RFC-4122-shaped v4 from Math.random. Uniqueness (not security)
27
+ // is all that's required for deduplication.
28
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (ch) => {
29
+ const r = (Math.random() * 16) | 0;
30
+ const v = ch === "x" ? r : (r & 0x3) | 0x8;
31
+ return v.toString(16);
32
+ });
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @clocknext/sdk — official ClockNext SDK.
3
+ *
4
+ * Record usage signals and manage customers from your backend. Server-side
5
+ * only: your `cnk_…` API key is a secret with org-wide write access.
6
+ */
7
+
8
+ export { ClockNext } from "./client.js";
9
+
10
+ export {
11
+ ClockNextError,
12
+ AuthError,
13
+ ForbiddenError,
14
+ ValidationError,
15
+ NotFoundError,
16
+ PlanError,
17
+ AllowanceError,
18
+ ConflictError,
19
+ RateLimitError,
20
+ ServerError,
21
+ NetworkError,
22
+ } from "./errors.js";
23
+
24
+ export type { ClockNextErrorCode } from "./errors.js";
25
+
26
+ export { signalToWire } from "./resources/signals.js";
27
+
28
+ export type {
29
+ // config
30
+ ClockNextConfig,
31
+ FetchLike,
32
+ Logger,
33
+ DropReason,
34
+ // signals
35
+ Signal,
36
+ SignalType,
37
+ SignalStatus,
38
+ SignalDelivery,
39
+ Tokens,
40
+ CompositeTag,
41
+ CreditSignal,
42
+ WalletSignal,
43
+ OutcomeSignal,
44
+ CreditInput,
45
+ WalletInput,
46
+ OutcomeInput,
47
+ TrackResult,
48
+ UsageList,
49
+ SerializedUsageLog,
50
+ UsageBalance,
51
+ SerializedRuleApplication,
52
+ SerializedOutcomeAttribution,
53
+ UnitInput,
54
+ UnitTrackResult,
55
+ CompositeCompletionInput,
56
+ CompositeCompletionResult,
57
+ UnitUsageRow,
58
+ UnitUsageList,
59
+ SerializedUnitUsage,
60
+ // workspace
61
+ WorkspaceIdentity,
62
+ Model,
63
+ // customers
64
+ ApiCustomer,
65
+ CustomerProfileInput,
66
+ CreateCustomerInput,
67
+ CustomerList,
68
+ CustomerBalances,
69
+ CreditBalance,
70
+ OutcomeBalance,
71
+ UnitBalance,
72
+ CustomerPlan,
73
+ CustomerPlanDetails,
74
+ // members
75
+ Member,
76
+ MemberProfile,
77
+ RemovedMember,
78
+ AddMemberInput,
79
+ UpdateMemberInput,
80
+ // wallet & balance adjustments
81
+ WalletTransaction,
82
+ WalletTransactionType,
83
+ WalletTransactionSource,
84
+ WalletEntitlementKind,
85
+ WalletList,
86
+ WalletEntryInput,
87
+ CreditAdjustInput,
88
+ OutcomeAdjustInput,
89
+ UnitAdjustInput,
90
+ // plans
91
+ Plan,
92
+ PlanDetail,
93
+ PlanComponent,
94
+ PlanComponentInput,
95
+ WalletComponentInput,
96
+ FlatComponentInput,
97
+ CreditComponentInput,
98
+ OutcomeComponentInput,
99
+ UnitComponentInput,
100
+ CompositeComponentInput,
101
+ CreatePlanInput,
102
+ UpdatePlanInput,
103
+ BillingCycle,
104
+ PlanType,
105
+ PlanKind,
106
+ ComponentType,
107
+ BillingMode,
108
+ // purchases
109
+ Purchase,
110
+ PurchaseDetail,
111
+ PurchaseStatus,
112
+ CreatePurchaseInput,
113
+ // invoices
114
+ Invoice,
115
+ InvoiceDetail,
116
+ InvoiceListRow,
117
+ InvoiceStatus,
118
+ InvoiceParty,
119
+ InvoiceLineItem,
120
+ InvoicePlanSnapshot,
121
+ PlanRef,
122
+ PayLink,
123
+ // payments
124
+ PaymentRow,
125
+ PaymentDetail,
126
+ // cost analytics (credits / outcomes)
127
+ CostPoint,
128
+ CostStats,
129
+ CreditAnalytics,
130
+ OutcomeAnalytics,
131
+ // credits catalog
132
+ Credit,
133
+ CreditDetail,
134
+ CreateCreditInput,
135
+ UpdateCreditInput,
136
+ ModelBundleEntry,
137
+ // outcomes catalog
138
+ Outcome,
139
+ OutcomeDetail,
140
+ OutcomeStepInput,
141
+ CreateOutcomeInput,
142
+ UpdateOutcomeInput,
143
+ // composites catalog
144
+ Composite,
145
+ CompositeCatalogueRef,
146
+ CreateCompositeInput,
147
+ // units catalog
148
+ Unit,
149
+ UnitDetail,
150
+ UnitCustomerRow,
151
+ UnitTier,
152
+ UnitTierInput,
153
+ UnitPricingType,
154
+ CreateUnitInput,
155
+ FlatUnitInput,
156
+ TieredUnitInput,
157
+ UpdateUnitInput,
158
+ // webhooks
159
+ Webhook,
160
+ UpsertWebhookInput,
161
+ ThresholdKind,
162
+ // portal
163
+ PortalTokenInput,
164
+ PortalToken,
165
+ } from "./types.js";
package/src/queue.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { Signal } from "./types.js";
2
+
3
+ /** One buffered, wire-ready signal awaiting a background flush. */
4
+ export interface QueuedSignal {
5
+ /**
6
+ * The route this signal posts to (`/api/v1/signal/credit` etc.), decided by
7
+ * `track()` when the signal was buffered.
8
+ *
9
+ * It is stored per item rather than fixed on the Flusher because each meter
10
+ * now has its own endpoint. A single hardcoded path was fine when everything
11
+ * went to `/api/v1/usage`; keeping one would have meant async mode silently
12
+ * posting to the deprecated route while sync mode used the new ones.
13
+ */
14
+ path: string;
15
+ /** The exact JSON body to POST. */
16
+ body: Record<string, unknown>;
17
+ /** The original signal — passed to onError/onDrop hooks. */
18
+ signal: Signal;
19
+ }
20
+
21
+ /**
22
+ * Internal buffer seam. Currently in-memory only — this interface exists so a
23
+ * durable (disk/Redis/SQLite) implementation can be slotted in later without
24
+ * touching the Flusher. It is NOT part of the public API and is not yet
25
+ * injectable; don't rely on it externally.
26
+ */
27
+ export interface QueueStore {
28
+ push(item: QueuedSignal): void;
29
+ /** Remove and return up to `n` items (FIFO). */
30
+ take(n: number): QueuedSignal[];
31
+ size(): number;
32
+ }
33
+
34
+ export class InMemoryQueue implements QueueStore {
35
+ private items: QueuedSignal[] = [];
36
+
37
+ push(item: QueuedSignal): void {
38
+ this.items.push(item);
39
+ }
40
+
41
+ take(n: number): QueuedSignal[] {
42
+ return this.items.splice(0, n);
43
+ }
44
+
45
+ size(): number {
46
+ return this.items.length;
47
+ }
48
+ }
@@ -0,0 +1,47 @@
1
+ import { activeQuery, filterByActive } from "../filters.js";
2
+ import type { Transport } from "../http.js";
3
+ import type { Composite, CreateCompositeInput } from "../types.js";
4
+
5
+ /**
6
+ * The `composites` resource — manage the org's composite *catalogue*: the
7
+ * bundles of credits / outcomes / units that are grouped under one tag and
8
+ * sold on a plan as a single thing. `/api/v1/composites`.
9
+ *
10
+ * Two things live elsewhere, deliberately:
11
+ * • Tagging a signal into a composite occurrence is `composite: { ref, value }`
12
+ * on the signal itself (see `signals.credit()` / `.outcome()` / `.unit()`).
13
+ * • Closing an occurrence — the moment it is billed — is
14
+ * `signals.completeComposite()`.
15
+ *
16
+ * This resource only defines what composites EXIST. Creating one prices it but
17
+ * charges nobody: an invoice is built from plan components, never from a
18
+ * catalogue entry, so a composite starts costing money only once a plan sells
19
+ * it (`quantity × price`, frozen into the purchase at buy time).
20
+ */
21
+ export class Composites {
22
+ constructor(private readonly transport: Transport) {}
23
+
24
+ /** List composites. Pass `{ active: true }` for only the ones a signal can
25
+ * still be tagged with — the default list INCLUDES archived ones, which
26
+ * keep every row they already own.
27
+ * `{ active: false }` returns only archived ones; omitting it returns
28
+ * both. See `activeQuery` — the API itself only supports `true`. */
29
+ async list(params: { active?: boolean } = {}): Promise<Composite[]> {
30
+ const res = await this.transport.request<{ composites: Composite[] }>({
31
+ method: "GET",
32
+ path: "/api/v1/composites",
33
+ query: { active: activeQuery(params.active) },
34
+ });
35
+ return filterByActive(res.composites, params.active);
36
+ }
37
+
38
+ /** Create a composite. `POST /api/v1/composites`. */
39
+ async create(input: CreateCompositeInput): Promise<Composite> {
40
+ const res = await this.transport.request<{ composite: Composite }>({
41
+ method: "POST",
42
+ path: "/api/v1/composites",
43
+ body: input,
44
+ });
45
+ return res.composite;
46
+ }
47
+ }
@@ -0,0 +1,76 @@
1
+ import { activeQuery, filterByActive } from "../filters.js";
2
+ import type { Transport } from "../http.js";
3
+ import type {
4
+ CreateCreditInput,
5
+ Credit,
6
+ CreditDetail,
7
+ UpdateCreditInput,
8
+ } from "../types.js";
9
+
10
+ /**
11
+ * The `credits` resource — manage the org's credit *types* (the catalog). A
12
+ * credit's stable `agentKey` is what you pass to `signals.credit()` when
13
+ * recording usage against it. `/api/v1/credits`.
14
+ */
15
+ export class Credits {
16
+ constructor(private readonly transport: Transport) {}
17
+
18
+ /** List credit types. Pass `{ active: true }` to only return active ones.
19
+ * `{ active: false }` returns only archived ones; omitting it returns
20
+ * both. See `activeQuery` — the API itself only supports `true`. */
21
+ async list(params: { active?: boolean } = {}): Promise<Credit[]> {
22
+ const res = await this.transport.request<{ credits: Credit[] }>({
23
+ method: "GET",
24
+ path: "/api/v1/credits",
25
+ query: { active: activeQuery(params.active) },
26
+ });
27
+ return filterByActive(res.credits, params.active);
28
+ }
29
+
30
+ /** Create a credit type; returns the created credit. `POST /api/v1/credits`. */
31
+ async create(input: CreateCreditInput): Promise<Credit> {
32
+ const res = await this.transport.request<{ credit: Credit }>({
33
+ method: "POST",
34
+ path: "/api/v1/credits",
35
+ body: input,
36
+ });
37
+ return res.credit;
38
+ }
39
+
40
+ /** Fetch one credit type with usage stats. `GET /api/v1/credits/:id`. */
41
+ async get(id: string): Promise<CreditDetail> {
42
+ const res = await this.transport.request<{ credit: CreditDetail }>({
43
+ method: "GET",
44
+ path: `/api/v1/credits/${encodeURIComponent(id)}`,
45
+ });
46
+ return res.credit;
47
+ }
48
+
49
+ /** Update a credit type's definition; returns the updated credit. `PATCH /api/v1/credits/:id`. */
50
+ async update(id: string, input: UpdateCreditInput): Promise<Credit> {
51
+ const res = await this.transport.request<{ credit: Credit }>({
52
+ method: "PATCH",
53
+ path: `/api/v1/credits/${encodeURIComponent(id)}`,
54
+ body: input,
55
+ });
56
+ return res.credit;
57
+ }
58
+
59
+ /** Activate or deactivate a credit type. */
60
+ async setActive(id: string, isActive: boolean): Promise<Credit> {
61
+ const res = await this.transport.request<{ credit: Credit }>({
62
+ method: "PATCH",
63
+ path: `/api/v1/credits/${encodeURIComponent(id)}`,
64
+ body: { isActive },
65
+ });
66
+ return res.credit;
67
+ }
68
+
69
+ /** Delete a credit type. Refused while a customer is linked to it. */
70
+ async delete(id: string): Promise<void> {
71
+ await this.transport.request({
72
+ method: "DELETE",
73
+ path: `/api/v1/credits/${encodeURIComponent(id)}`,
74
+ });
75
+ }
76
+ }