@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.
- package/README.md +38 -15
- package/dist/auth/token-verifier.d.ts +16 -2
- package/dist/auth/token-verifier.js +34 -7
- package/dist/client/connection-supervisor.d.ts +32 -1
- package/dist/client/connection-supervisor.js +109 -6
- package/dist/client/shiprev-client.d.ts +38 -3
- package/dist/client/shiprev-client.js +146 -6
- package/dist/mirror/degradation.d.ts +22 -0
- package/dist/mirror/degradation.js +67 -0
- package/dist/mirror/entitlement-mirror.d.ts +15 -1
- package/dist/mirror/entitlement-mirror.js +36 -2
- package/dist/ruling/adjudicator.js +5 -12
- package/dist/transport/http.transport.d.ts +6 -1
- package/dist/transport/http.transport.js +43 -2
- package/dist/types.d.ts +106 -2
- package/dist/types.js +48 -1
- package/package.json +10 -11
|
@@ -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
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
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
|
-
*
|
|
149
|
-
*
|
|
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 =
|
|
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 =
|
|
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
|
|
90
|
-
//
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|