@shiprev/server-sdk-node 0.1.0-beta.0 → 0.1.0-beta.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.
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ShipRevClient = void 0;
4
4
  const token_verifier_1 = require("../auth/token-verifier");
5
5
  const usage_buffer_1 = require("../metering/usage-buffer");
6
+ const degradation_1 = require("../mirror/degradation");
6
7
  const entitlement_mirror_1 = require("../mirror/entitlement-mirror");
7
8
  const adjudicator_1 = require("../ruling/adjudicator");
8
9
  const http_transport_1 = require("../transport/http.transport");
@@ -23,6 +24,10 @@ class ShipRevClient {
23
24
  expectations = null;
24
25
  /** Amounts reserved by this client, so `confirm()` without an amount confirms what was reserved. */
25
26
  reservedAmounts = new Map();
27
+ /** Effective levels last reported through `on_level_change`. */
28
+ reportedLevels = new Map();
29
+ /** Fires when the earliest `valid_until` passes while disconnected, to report the expiry. */
30
+ expiryTimer = null;
26
31
  constructor(options) {
27
32
  this.options = options;
28
33
  this.transport = new http_transport_1.HttpTransport({
@@ -30,7 +35,7 @@ class ShipRevClient {
30
35
  connectTimeoutMs: options.presets?.connect_timeout_ms,
31
36
  });
32
37
  this.tokenVerifier = new token_verifier_1.TokenVerifier(this.transport);
33
- this.mirror = new entitlement_mirror_1.EntitlementMirror();
38
+ this.mirror = new entitlement_mirror_1.EntitlementMirror((organizationIds) => this.onMirrorChange(organizationIds));
34
39
  this.adjudicator = new adjudicator_1.Adjudicator(this.mirror, options.presets);
35
40
  this.supervisor = new connection_supervisor_1.ConnectionSupervisor({
36
41
  transport: this.transport,
@@ -74,6 +79,10 @@ class ShipRevClient {
74
79
  */
75
80
  async stop() {
76
81
  this.supervisor.stop();
82
+ if (this.expiryTimer) {
83
+ clearTimeout(this.expiryTimer);
84
+ this.expiryTimer = null;
85
+ }
77
86
  await this.usageBuffer.stop();
78
87
  this.active = false;
79
88
  this.isOnline = false;
@@ -115,9 +124,12 @@ class ShipRevClient {
115
124
  if (ruling.allowed || ruling.source === 'degraded' || !this.isOnline) {
116
125
  return ruling;
117
126
  }
118
- // When the mirror misses the organization or capability and the client is online, ask the
119
- // service for an authoritative ruling, then refill that organization from the snapshot endpoint.
120
- if (this.supervisor.currentToken() && ruling.reason === 'capability_not_granted') {
127
+ // Two cases are not decided by the mirror, and the service is asked for an authoritative ruling:
128
+ // the mirror misses the organization or capability, or the write goes beyond the allowance the mirror
129
+ // counts (credits and overdraft are the service's state). The mirror's refusal stands when no ruling comes.
130
+ const beyondAllowance = (ruling.reason === 'quota_exhausted' || ruling.reason === 'plan_upgrade_required') &&
131
+ ruling.source === 'realtime';
132
+ if (this.supervisor.currentToken() && (ruling.reason === 'capability_not_granted' || beyondAllowance)) {
121
133
  try {
122
134
  const remote = await this.supervisor.authorized((token) => this.transport.lookupAdjudication({
123
135
  organization_id: options.organization_id,
@@ -145,12 +157,77 @@ class ShipRevClient {
145
157
  return ruling;
146
158
  }
147
159
  /**
148
- * Guard for write operations (fail-closed).
149
- * Intercepts action if write operation is denied (e.g. disconnected).
160
+ * The effective degradation level of an organization: the level of the mirror entry while the instance
161
+ * answers; without an answer, the entry's level until its `valid_until`, then at least `RESTRICT_USAGE`
162
+ * (marked `expired`). `undefined` when the mirror does not hold the organization. Synchronous, no I/O.
163
+ *
164
+ * It does not change `adjudicate()` or `guardWrite()`: a disconnected write is refused whatever the level.
165
+ */
166
+ effectiveLevel(organizationId) {
167
+ const entry = this.mirror.getOrganization(organizationId);
168
+ return entry ? (0, degradation_1.combineLevel)(entry, this.isOnline, Date.now()) : undefined;
169
+ }
170
+ /**
171
+ * Pulls one organization now (sharing a pull already in flight for it) and resolves with the level of the
172
+ * answer, `source: 'answer'`. When the pull fails or does not finish within `timeout_ms` (default: the
173
+ * connect timeout), resolves with `effectiveLevel()` as it stands. `undefined` when the instance no longer
174
+ * holds the organization, or the mirror never did.
175
+ */
176
+ async refresh(organizationId, options) {
177
+ const timeoutMs = options?.timeout_ms ?? this.options.presets?.connect_timeout_ms ?? 5000;
178
+ let timer;
179
+ try {
180
+ const pulled = await Promise.race([
181
+ this.supervisor.pullOrganization(organizationId),
182
+ new Promise((_resolve, reject) => {
183
+ timer = setTimeout(() => reject(new Error('refresh timed out')), timeoutMs);
184
+ timer.unref();
185
+ }),
186
+ ]);
187
+ // Only an answer the mirror took is an answer; a stale or unsupported one leaves the level as it stands.
188
+ if (pulled.outcome === 'applied' && pulled.entry)
189
+ return (0, degradation_1.combineLevel)(pulled.entry, true, Date.now());
190
+ if (pulled.outcome === 'removed')
191
+ return undefined;
192
+ return this.effectiveLevel(organizationId);
193
+ }
194
+ catch {
195
+ return this.effectiveLevel(organizationId);
196
+ }
197
+ finally {
198
+ if (timer)
199
+ clearTimeout(timer);
200
+ }
201
+ }
202
+ /**
203
+ * Sends a batch of usage events at once, outside the buffer and the backlog, and resolves with what the
204
+ * instance did with each. A 401 renews the token once, as every request does. Throws `UsageDeliveryError`
205
+ * when the batch did not reach the instance or it failed to process it (network error, timeout, 5xx); any
206
+ * other refusal throws as it came. Sending the same batch again is safe: events are deduplicated by key.
207
+ */
208
+ async sendUsage(events) {
209
+ try {
210
+ return await this.supervisor.authorized((token) => this.transport.sendUsageEvents(events, token));
211
+ }
212
+ catch (error) {
213
+ if (error instanceof http_transport_1.HttpError && error.status < 500)
214
+ throw error;
215
+ throw new types_1.UsageDeliveryError(error instanceof Error ? error.message : 'Usage events could not be delivered');
216
+ }
217
+ }
218
+ /**
219
+ * Guard for write operations (fail-closed): runs `action` only when the local ruling allows the write.
220
+ * A refusal for funds throws `QuotaExhaustedError` (`quota_exhausted`) or `PlanUpgradeRequiredError`
221
+ * (`plan_upgrade_required`, PROMPT_UPGRADE); any other refusal, a disconnected write included, throws
222
+ * `DisconnectedWriteError` as before.
150
223
  */
151
224
  async guardWrite(options, action) {
152
225
  const ruling = this.adjudicate({ ...options, operation: 'write' });
153
226
  if (!ruling.allowed) {
227
+ if (ruling.reason === 'quota_exhausted')
228
+ throw new types_1.QuotaExhaustedError();
229
+ if (ruling.reason === 'plan_upgrade_required')
230
+ throw new types_1.PlanUpgradeRequiredError();
154
231
  throw new types_1.DisconnectedWriteError(ruling.reason === 'disconnected_write'
155
232
  ? 'Write operation denied while client is disconnected'
156
233
  : `Write operation denied: ${ruling.reason ?? 'not allowed'}`);
@@ -245,7 +322,70 @@ class ShipRevClient {
245
322
  return this.transport;
246
323
  }
247
324
  applyOnline(online) {
325
+ const changed = this.isOnline !== online;
248
326
  this.isOnline = online;
327
+ if (changed) {
328
+ this.notifyLevels(this.mirror.organizationIds());
329
+ this.armExpiryTimer();
330
+ }
331
+ }
332
+ onMirrorChange(organizationIds) {
333
+ if (organizationIds === null) {
334
+ // The whole mirror was replaced: organizations that left it are forgotten.
335
+ for (const organizationId of [...this.reportedLevels.keys()]) {
336
+ if (!this.mirror.hasOrganization(organizationId))
337
+ this.reportedLevels.delete(organizationId);
338
+ }
339
+ }
340
+ this.notifyLevels(organizationIds ?? this.mirror.organizationIds());
341
+ this.armExpiryTimer();
342
+ this.supervisor?.scheduleRenewals();
343
+ }
344
+ /** Reports every organization whose effective level differs from what was last reported. */
345
+ notifyLevels(organizationIds) {
346
+ const listener = this.options.on_level_change;
347
+ if (!listener)
348
+ return;
349
+ for (const organizationId of organizationIds) {
350
+ const level = this.effectiveLevel(organizationId);
351
+ if (!level) {
352
+ this.reportedLevels.delete(organizationId);
353
+ continue;
354
+ }
355
+ if ((0, degradation_1.sameLevel)(level, this.reportedLevels.get(organizationId)))
356
+ continue;
357
+ this.reportedLevels.set(organizationId, level);
358
+ try {
359
+ listener(organizationId, level);
360
+ }
361
+ catch {
362
+ // A listener failure never reaches the connection.
363
+ }
364
+ }
365
+ }
366
+ /** While disconnected, wakes up when the earliest entry still valid expires, to report it. */
367
+ armExpiryTimer() {
368
+ if (this.expiryTimer) {
369
+ clearTimeout(this.expiryTimer);
370
+ this.expiryTimer = null;
371
+ }
372
+ if (!this.options.on_level_change || this.isOnline || !this.active)
373
+ return;
374
+ const now = Date.now();
375
+ let next = Number.POSITIVE_INFINITY;
376
+ for (const organizationId of this.mirror.organizationIds()) {
377
+ const validUntil = (0, degradation_1.validUntilOf)(this.mirror.getOrganization(organizationId));
378
+ if (validUntil !== undefined && validUntil > now)
379
+ next = Math.min(next, validUntil);
380
+ }
381
+ if (next === Number.POSITIVE_INFINITY)
382
+ return;
383
+ this.expiryTimer = setTimeout(() => {
384
+ this.expiryTimer = null;
385
+ this.notifyLevels(this.mirror.organizationIds());
386
+ this.armExpiryTimer();
387
+ }, next - now);
388
+ this.expiryTimer.unref();
249
389
  }
250
390
  learnExpectations(token) {
251
391
  const expectations = (0, token_verifier_1.expectationsFromMachineToken)(token);
@@ -0,0 +1,22 @@
1
+ import { DegradationLevel, EffectiveLevel, OrganizationSnapshot } from '../types';
2
+ /** The five levels in their strict order; each blocks what the one before it blocks, and more. */
3
+ export declare const DEGRADATION_LEVELS: readonly DegradationLevel[];
4
+ /** What a replica past its `valid_until` is tightened to, at least. */
5
+ export declare const EXPIRED_FLOOR: DegradationLevel;
6
+ /**
7
+ * The level an organization entry carries. Anything that is not one of the five values (missing, another
8
+ * string, not a string) is read as RESTRICT_USAGE: a level this client does not know is never read as looser.
9
+ */
10
+ export declare function levelOf(entry: OrganizationSnapshot): DegradationLevel;
11
+ /** `valid_until` in epoch milliseconds, or undefined when missing or unparsable (read as already expired). */
12
+ export declare function validUntilOf(entry: OrganizationSnapshot): number | undefined;
13
+ /**
14
+ * Combines an entry with what the client observes:
15
+ *
16
+ * - answered (online, or a pull of this organization just succeeded): the entry's level;
17
+ * - not answered and `now < valid_until`: the entry's level, marked degraded;
18
+ * - not answered and `now >= valid_until` (or no usable `valid_until`): at least RESTRICT_USAGE, marked expired.
19
+ */
20
+ export declare function combineLevel(entry: OrganizationSnapshot, answered: boolean, now: number): EffectiveLevel;
21
+ /** True when two effective levels are the same triple. */
22
+ export declare function sameLevel(a: EffectiveLevel | undefined, b: EffectiveLevel | undefined): boolean;
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EXPIRED_FLOOR = exports.DEGRADATION_LEVELS = void 0;
4
+ exports.levelOf = levelOf;
5
+ exports.validUntilOf = validUntilOf;
6
+ exports.combineLevel = combineLevel;
7
+ exports.sameLevel = sameLevel;
8
+ /** The five levels in their strict order; each blocks what the one before it blocks, and more. */
9
+ exports.DEGRADATION_LEVELS = [
10
+ 'NONE',
11
+ 'RESTRICT_NEW',
12
+ 'RESTRICT_USAGE',
13
+ 'RESTRICT_READ',
14
+ 'SUSPENDED',
15
+ ];
16
+ /** What a replica past its `valid_until` is tightened to, at least. */
17
+ exports.EXPIRED_FLOOR = 'RESTRICT_USAGE';
18
+ function rank(level) {
19
+ return exports.DEGRADATION_LEVELS.indexOf(level);
20
+ }
21
+ /**
22
+ * The level an organization entry carries. Anything that is not one of the five values (missing, another
23
+ * string, not a string) is read as RESTRICT_USAGE: a level this client does not know is never read as looser.
24
+ */
25
+ function levelOf(entry) {
26
+ const raw = entry.degradation_level;
27
+ return typeof raw === 'string' && exports.DEGRADATION_LEVELS.includes(raw)
28
+ ? raw
29
+ : exports.EXPIRED_FLOOR;
30
+ }
31
+ /** `valid_until` in epoch milliseconds, or undefined when missing or unparsable (read as already expired). */
32
+ function validUntilOf(entry) {
33
+ const raw = entry.valid_until;
34
+ if (typeof raw !== 'string')
35
+ return undefined;
36
+ const at = Date.parse(raw);
37
+ return Number.isFinite(at) ? at : undefined;
38
+ }
39
+ /**
40
+ * Combines an entry with what the client observes:
41
+ *
42
+ * - answered (online, or a pull of this organization just succeeded): the entry's level;
43
+ * - not answered and `now < valid_until`: the entry's level, marked degraded;
44
+ * - not answered and `now >= valid_until` (or no usable `valid_until`): at least RESTRICT_USAGE, marked expired.
45
+ */
46
+ function combineLevel(entry, answered, now) {
47
+ const level = levelOf(entry);
48
+ if (answered) {
49
+ return { level, source: 'answer', expired: false };
50
+ }
51
+ const validUntil = validUntilOf(entry);
52
+ if (validUntil !== undefined && now < validUntil) {
53
+ return { level, source: 'degraded', expired: false };
54
+ }
55
+ return {
56
+ level: rank(level) >= rank(exports.EXPIRED_FLOOR) ? level : exports.EXPIRED_FLOOR,
57
+ source: 'degraded',
58
+ expired: true,
59
+ };
60
+ }
61
+ /** True when two effective levels are the same triple. */
62
+ function sameLevel(a, b) {
63
+ if (!a || !b)
64
+ return a === b;
65
+ return a.level === b.level && a.source === b.source && a.expired === b.expired;
66
+ }
67
+ //# sourceMappingURL=degradation.js.map
@@ -1,14 +1,22 @@
1
- import { Snapshot, SnapshotItem } from '../types';
1
+ import { OrganizationSnapshot, Snapshot, SnapshotItem } from '../types';
2
2
  export declare const MAX_SUPPORTED_SCHEMA_VERSION = 1;
3
3
  /**
4
4
  * In-memory replica of the application-level entitlement snapshot served by
5
5
  * `GET /sdk/v1/entitlements/snapshot`, indexed by organization and capability.
6
6
  */
7
7
  export declare class EntitlementMirror {
8
+ private readonly onChange?;
8
9
  private readonly organizations;
10
+ /** When each organization's entry was received (local clock). */
11
+ private readonly receivedAt;
9
12
  private currentSchemaVersion;
10
13
  private projectId;
11
14
  private lastUpdatedAt;
15
+ /**
16
+ * @param onChange called after the mirror changed: with the organizations whose entry was replaced or
17
+ * removed, or with `null` when the whole mirror was replaced.
18
+ */
19
+ constructor(onChange?: ((organizationIds: string[] | null) => void) | undefined);
12
20
  /**
13
21
  * Replaces the mirror with an application-level snapshot envelope.
14
22
  * If schema_version is higher than supported, the update is rejected and the existing mirror is retained.
@@ -23,6 +31,12 @@ export declare class EntitlementMirror {
23
31
  * Removes an organization, for a refill whose envelope no longer holds it (unsubscribed or deleted).
24
32
  */
25
33
  removeOrganization(organizationId: string): void;
34
+ /** The entry of one organization, or `undefined` when the mirror does not hold it. */
35
+ getOrganization(organizationId: string): OrganizationSnapshot | undefined;
36
+ /** When the entry of one organization was received (local clock), or `undefined`. */
37
+ getReceivedAt(organizationId: string): number | undefined;
38
+ /** The organizations the mirror holds. */
39
+ organizationIds(): string[];
26
40
  /**
27
41
  * Revision of one organization, or `undefined` when the mirror does not hold it.
28
42
  */
@@ -7,10 +7,20 @@ exports.MAX_SUPPORTED_SCHEMA_VERSION = 1;
7
7
  * `GET /sdk/v1/entitlements/snapshot`, indexed by organization and capability.
8
8
  */
9
9
  class EntitlementMirror {
10
+ onChange;
10
11
  organizations = new Map();
12
+ /** When each organization's entry was received (local clock). */
13
+ receivedAt = new Map();
11
14
  currentSchemaVersion = exports.MAX_SUPPORTED_SCHEMA_VERSION;
12
15
  projectId = '';
13
16
  lastUpdatedAt = 0;
17
+ /**
18
+ * @param onChange called after the mirror changed: with the organizations whose entry was replaced or
19
+ * removed, or with `null` when the whole mirror was replaced.
20
+ */
21
+ constructor(onChange) {
22
+ this.onChange = onChange;
23
+ }
14
24
  /**
15
25
  * Replaces the mirror with an application-level snapshot envelope.
16
26
  * If schema_version is higher than supported, the update is rejected and the existing mirror is retained.
@@ -19,13 +29,17 @@ class EntitlementMirror {
19
29
  if (!this.acceptsEnvelope(snapshot)) {
20
30
  return false;
21
31
  }
32
+ const now = Date.now();
22
33
  this.organizations.clear();
34
+ this.receivedAt.clear();
23
35
  this.projectId = snapshot.project_id;
24
36
  this.currentSchemaVersion = snapshot.schema_version;
25
37
  for (const [organizationId, organization] of Object.entries(snapshot.organizations ?? {})) {
26
38
  this.organizations.set(organizationId, organization);
39
+ this.receivedAt.set(organizationId, now);
27
40
  }
28
- this.lastUpdatedAt = Date.now();
41
+ this.lastUpdatedAt = now;
42
+ this.onChange?.(null);
29
43
  return true;
30
44
  }
31
45
  /**
@@ -39,14 +53,20 @@ class EntitlementMirror {
39
53
  if (this.projectId && snapshot.project_id !== this.projectId) {
40
54
  return false;
41
55
  }
56
+ const now = Date.now();
57
+ const changed = [];
42
58
  this.projectId = snapshot.project_id;
43
59
  for (const [organizationId, organization] of Object.entries(snapshot.organizations ?? {})) {
44
60
  const known = this.organizations.get(organizationId);
45
61
  if (!known || organization.revision >= known.revision) {
46
62
  this.organizations.set(organizationId, organization);
63
+ this.receivedAt.set(organizationId, now);
64
+ changed.push(organizationId);
47
65
  }
48
66
  }
49
- this.lastUpdatedAt = Date.now();
67
+ this.lastUpdatedAt = now;
68
+ if (changed.length > 0)
69
+ this.onChange?.(changed);
50
70
  return true;
51
71
  }
52
72
  /**
@@ -54,9 +74,23 @@ class EntitlementMirror {
54
74
  */
55
75
  removeOrganization(organizationId) {
56
76
  if (this.organizations.delete(organizationId)) {
77
+ this.receivedAt.delete(organizationId);
57
78
  this.lastUpdatedAt = Date.now();
79
+ this.onChange?.([organizationId]);
58
80
  }
59
81
  }
82
+ /** The entry of one organization, or `undefined` when the mirror does not hold it. */
83
+ getOrganization(organizationId) {
84
+ return this.organizations.get(organizationId);
85
+ }
86
+ /** When the entry of one organization was received (local clock), or `undefined`. */
87
+ getReceivedAt(organizationId) {
88
+ return this.receivedAt.get(organizationId);
89
+ }
90
+ /** The organizations the mirror holds. */
91
+ organizationIds() {
92
+ return [...this.organizations.keys()];
93
+ }
60
94
  /**
61
95
  * Revision of one organization, or `undefined` when the mirror does not hold it.
62
96
  */
@@ -86,23 +86,16 @@ class Adjudicator {
86
86
  adjudicated_at: now,
87
87
  };
88
88
  }
89
- // Beyond the remaining allowance only ALLOW_AND_BILL lets the call through locally; any other
90
- // policy depends on prices and wallet state the mirror does not hold, so it is refused.
91
- if (item.overage_policy === 'ALLOW_AND_BILL') {
92
- return {
93
- allowed: true,
94
- remaining: 0,
95
- limit,
96
- source: 'realtime',
97
- adjudicated_at: now,
98
- };
99
- }
89
+ // Beyond the remaining allowance the call is not decided here, whatever the policy: whether credits (and, for
90
+ // ALLOW_AND_BILL, an overdraft) cover it is state the mirror does not hold. The local answer is a refusal;
91
+ // adjudicateAsync() asks the service on it, and without an answer the call stays refused (fail closed).
100
92
  return {
101
93
  allowed: false,
102
94
  remaining: (0, decimal_1.decimalToNumber)(item.remaining),
103
95
  limit,
104
96
  source: 'realtime',
105
- reason: 'quota_exhausted',
97
+ // Under PROMPT_UPGRADE the way on is a larger plan, not more credits; the service rules the same way.
98
+ reason: item.overage_policy === 'PROMPT_UPGRADE' ? 'plan_upgrade_required' : 'quota_exhausted',
106
99
  adjudicated_at: now,
107
100
  };
108
101
  }
@@ -1,4 +1,4 @@
1
- import { AdjudicationLookupRequest, AdjudicationLookupResponse, ConfirmReservationResult, JwksResponse, ReleaseReservationResult, ReservationResult, ReserveOptions, Snapshot, UsageEventItem, UsageEventsResponse } from '../types';
1
+ import { AdjudicationLookupRequest, AdjudicationLookupResponse, ConfirmReservationResult, JwksResponse, ReleaseReservationResult, ReservationResult, ReserveOptions, Snapshot, UsageEventItem, UsageEventsResponse, UsageSendEvent, UsageSendResult } from '../types';
2
2
  /** A non-success HTTP answer from the instance; `status` lets callers tell a refused token from an outage. */
3
3
  export declare class HttpError extends Error {
4
4
  readonly status: number;
@@ -37,6 +37,11 @@ export declare class HttpTransport {
37
37
  * Quantities are sent as decimal strings; `properties` travel as the event's `dimensions`.
38
38
  */
39
39
  reportUsageEvents(events: UsageEventItem[], token: string): Promise<UsageEventsResponse>;
40
+ /**
41
+ * Sends a batch of usage events as they are: POST /sdk/v1/usage-events. A string quantity travels unchanged,
42
+ * so decimals keep every digit.
43
+ */
44
+ sendUsageEvents(events: UsageSendEvent[], token: string): Promise<UsageSendResult>;
40
45
  /**
41
46
  * Authoritative ruling when the mirror misses an organization or capability: POST /sdk/v1/adjudicate.
42
47
  */
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HttpTransport = exports.HttpError = void 0;
4
4
  exports.isHttpStatus = isHttpStatus;
5
+ const types_1 = require("../types");
5
6
  const decimal_1 = require("../util/decimal");
6
7
  /** A non-success HTTP answer from the instance; `status` lets callers tell a refused token from an outage. */
7
8
  class HttpError extends Error {
@@ -17,6 +18,25 @@ exports.HttpError = HttpError;
17
18
  function isHttpStatus(error, ...statuses) {
18
19
  return error instanceof HttpError && statuses.includes(error.status);
19
20
  }
21
+ /**
22
+ * The typed error of a refusal for funds, read from `error.code` of the instance's error envelope; undefined for any
23
+ * other answer. Only the code decides: the message text is never parsed for meaning.
24
+ */
25
+ function fundsRefusal(errorText, status, message) {
26
+ let error;
27
+ try {
28
+ error = JSON.parse(errorText)?.error;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ const details = { status, request_id: typeof error?.request_id === 'string' ? error.request_id : undefined };
34
+ if (error?.code === 'QUOTA_EXHAUSTED')
35
+ return new types_1.QuotaExhaustedError(message, details);
36
+ if (error?.code === 'PLAN_UPGRADE_REQUIRED')
37
+ return new types_1.PlanUpgradeRequiredError(message, details);
38
+ return undefined;
39
+ }
20
40
  class HttpTransport {
21
41
  baseUrl;
22
42
  timeoutMs;
@@ -97,6 +117,26 @@ class HttpTransport {
97
117
  rejected: body.rejected ?? [],
98
118
  };
99
119
  }
120
+ /**
121
+ * Sends a batch of usage events as they are: POST /sdk/v1/usage-events. A string quantity travels unchanged,
122
+ * so decimals keep every digit.
123
+ */
124
+ async sendUsageEvents(events, token) {
125
+ const wireEvents = events.map((event) => ({
126
+ usage_event_id: event.usage_event_id,
127
+ organization_id: event.organization_id,
128
+ capability_key: event.capability_key,
129
+ quantity: typeof event.quantity === 'string' ? event.quantity.trim() : (0, decimal_1.toDecimalString)(event.quantity),
130
+ occurred_at: event.occurred_at,
131
+ ...(event.properties !== undefined ? { dimensions: event.properties } : {}),
132
+ }));
133
+ const body = await this.postJson('/sdk/v1/usage-events', { events: wireEvents }, token, 'Failed to send usage events');
134
+ return {
135
+ accepted_count: body.accepted_count,
136
+ duplicates_dropped: body.duplicates_dropped,
137
+ rejected: body.rejected ?? [],
138
+ };
139
+ }
100
140
  /**
101
141
  * Authoritative ruling when the mirror misses an organization or capability: POST /sdk/v1/adjudicate.
102
142
  */
@@ -129,7 +169,7 @@ class HttpTransport {
129
169
  const body = await this.postJson('/sdk/v1/reservations/reserve', wireRequest, token, 'Reservation failed');
130
170
  return {
131
171
  reservation_id: body.reservation_id,
132
- status: body.status === 'RESERVED' ? 'PENDING' : body.status,
172
+ status: body.status,
133
173
  expires_at: body.expires_at,
134
174
  };
135
175
  }
@@ -185,7 +225,8 @@ class HttpTransport {
185
225
  });
186
226
  if (!response.ok) {
187
227
  const errorText = await response.text();
188
- throw new HttpError(`${failure}: HTTP ${response.status} - ${errorText}`, response.status);
228
+ const message = `${failure}: HTTP ${response.status} - ${errorText}`;
229
+ throw fundsRefusal(errorText, response.status, message) ?? new HttpError(message, response.status);
189
230
  }
190
231
  return (await response.json());
191
232
  }