@allisall/sdk 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +227 -2
- package/dist/client.d.ts +121 -0
- package/dist/client.js +193 -0
- package/dist/decimal.d.ts +20 -0
- package/dist/decimal.js +23 -0
- package/dist/errors.d.ts +79 -0
- package/dist/errors.js +98 -0
- package/dist/generated/openapi.d.ts +7724 -0
- package/dist/generated/openapi.js +4 -0
- package/dist/generated/operations.d.ts +37 -0
- package/dist/generated/operations.js +976 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +5 -0
- package/dist/ingest.d.ts +28 -0
- package/dist/ingest.js +247 -0
- package/dist/money.d.ts +57 -0
- package/dist/money.js +284 -0
- package/dist/multipart.d.ts +27 -0
- package/dist/multipart.js +48 -0
- package/dist/options.d.ts +50 -0
- package/dist/options.js +73 -0
- package/dist/pagination.d.ts +55 -0
- package/dist/pagination.js +85 -0
- package/dist/request.d.ts +117 -0
- package/dist/request.js +363 -0
- package/dist/resources/agents.d.ts +51 -0
- package/dist/resources/agents.js +65 -0
- package/dist/resources/analytics.d.ts +42 -0
- package/dist/resources/analytics.js +68 -0
- package/dist/resources/api-keys.d.ts +54 -0
- package/dist/resources/api-keys.js +63 -0
- package/dist/resources/calculations.d.ts +78 -0
- package/dist/resources/calculations.js +111 -0
- package/dist/resources/clients.d.ts +65 -0
- package/dist/resources/clients.js +85 -0
- package/dist/resources/cost.d.ts +69 -0
- package/dist/resources/cost.js +89 -0
- package/dist/resources/dashboard.d.ts +24 -0
- package/dist/resources/dashboard.js +28 -0
- package/dist/resources/events.d.ts +47 -0
- package/dist/resources/events.js +69 -0
- package/dist/resources/health.d.ts +14 -0
- package/dist/resources/health.js +17 -0
- package/dist/resources/imports.d.ts +33 -0
- package/dist/resources/imports.js +37 -0
- package/dist/resources/outcome-types.d.ts +25 -0
- package/dist/resources/outcome-types.js +33 -0
- package/dist/resources/reports.d.ts +77 -0
- package/dist/resources/reports.js +103 -0
- package/dist/resources/value-models.d.ts +92 -0
- package/dist/resources/value-models.js +126 -0
- package/dist/retry.d.ts +18 -0
- package/dist/retry.js +105 -0
- package/dist/runtime.d.ts +19 -0
- package/dist/runtime.js +61 -0
- package/dist/share.d.ts +52 -0
- package/dist/share.js +60 -0
- package/dist/transport.d.ts +102 -0
- package/dist/transport.js +269 -0
- package/dist/types.d.ts +268 -0
- package/dist/types.js +89 -0
- package/dist/unicode.d.ts +2 -0
- package/dist/unicode.js +11 -0
- package/dist/uuid.d.ts +6 -0
- package/dist/uuid.js +20 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +40 -4
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { Allisall, type ClientOptions } from "./client.js";
|
|
2
|
+
export { type Decimal, decimal } from "./decimal.js";
|
|
3
|
+
export { AllisallError, ApiError, ConnectionError, IngestError, } from "./errors.js";
|
|
4
|
+
export type { MoneyInput } from "./money.js";
|
|
5
|
+
export type { ImportUpload } from "./multipart.js";
|
|
6
|
+
export type { IdempotentRequestOptions, RequestOptions } from "./options.js";
|
|
7
|
+
export type { Page, PagePromise } from "./pagination.js";
|
|
8
|
+
export { getSharedReport, type SharedReportOptions } from "./share.js";
|
|
9
|
+
export type { FetchLike, TokenProvider } from "./transport.js";
|
|
10
|
+
export type { Agent, AgentCostLine, AgentCreate, AgentDetail, AgentList, AgentListItem, AgentPatch, AgentStats, AgentStatus, Anomalies, ApiKey, ApiKeyCreate, ApiKeyList, ApiKeyWithToken, AssignedAgent, AttributedEvent, Calculation, CalculationCreate, CalculationList, CalculationSnapshot, CalculationState, CalculationSummary, Checklist, ChecklistInput, Client, ClientAgents, ClientAgentsSet, ClientCost, ClientCreate, ClientDetail, ClientEventList, ClientList, ClientPatch, ClientStatus, ClientValue, CostCoverage, CostLineItem, CostLineItemCreate, CostLineItemList, CostLineItemPatch, CostResult, CostTarget, CostTargetInput, CostTargetKind, Dashboard, Disclosure, DisclosureEdit, DisclosureKind, ErrorCode, EventCategory, EventInput, EventSource, FieldError, FieldErrorCode, GetAgentQuery, GetClientAnomaliesQuery, GetClientCostQuery, GetClientRoiQuery, GetClientValueQuery, GetDashboardQuery, Health, HealthDirection, HealthUnavailable, ImportMapping, ImportPreview, ImportResult, ImportRowError, IngestRowError, IngestSummary, ListAgentsQuery, ListApiKeysQuery, ListCalculationReportsQuery, ListClientCalculationsQuery, ListClientEventsQuery, ListClientOutcomeTypesQuery, ListClientsQuery, ListCostLineItemsQuery, ListOutcomeTypesQuery, ListRenewalsQuery, ListValueModelsQuery, ListValueTemplatesQuery, ManualCostLine, ModelRate, ModelRateInput, OutcomeTypeList, OutcomeTypeUsage, Period, PeriodInput, PortfolioClient, PortfolioExclusionReason, PreviewEvent, PreviewRow, PriceTable, PriceTableSet, Problem, PublicReport, Ratios, Renewal, RenewalList, Report, ReportList, ReportPatch, ReportSummary, ReportWithPreview, RoiResult, Scope, TokenCostLine, UnattributedLineItem, UndefinedReason, ValueCoverage, ValueLine, ValueLineItem, ValueLineItemInput, ValueLineItemsSet, ValueModel, ValueModelCreate, ValueModelList, ValueModelPatch, ValueModelRef, ValueModelStatus, ValueModelSummary, ValueResult, ValueTemplate, ValueTemplateLineItem, ValueTemplateList, } from "./types.js";
|
package/dist/index.js
ADDED
package/dist/ingest.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { EventInput, IngestResult, IngestSummary } from "./types.js";
|
|
2
|
+
/** The most events one batch holds. */
|
|
3
|
+
export declare const MAX_BATCH_EVENTS = 500;
|
|
4
|
+
/** The API's body limit for a batch, in bytes, inclusive. */
|
|
5
|
+
export declare const MAX_BODY_BYTES = 4000000;
|
|
6
|
+
/** The largest event that fits in a batch alone. */
|
|
7
|
+
export declare const MAX_EVENT_BYTES: number;
|
|
8
|
+
/** Sends one batch body of `count` events and resolves with the API's outcome for it. */
|
|
9
|
+
export type SendBatch = (body: Uint8Array, count: number, signal: AbortSignal | undefined) => Promise<IngestResult>;
|
|
10
|
+
/**
|
|
11
|
+
* Encodes one event as compact JSON. Every member the object holds is
|
|
12
|
+
* written, in its own order; a member set to `undefined` is omitted, as
|
|
13
|
+
* `JSON.stringify` omits it.
|
|
14
|
+
*/
|
|
15
|
+
export declare function encodeEvent(event: EventInput, index: number): string;
|
|
16
|
+
/**
|
|
17
|
+
* Reads a `2xx` ingest body for a batch of `count` events as an
|
|
18
|
+
* `IngestResult`, or `undefined` when it is not one. A row error whose
|
|
19
|
+
* `index` is not a position in the batch makes the body unreadable, as a
|
|
20
|
+
* body of the wrong shape is.
|
|
21
|
+
*/
|
|
22
|
+
export declare function readIngestResult(value: unknown, count: number): IngestResult | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Sends any number of events in as many batches as the limits require,
|
|
25
|
+
* one after another, and combines their outcomes. Stops at the first batch
|
|
26
|
+
* that fails, throwing an `IngestError`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function ingest(events: readonly EventInput[], send: SendBatch, signal: AbortSignal | undefined): Promise<IngestSummary>;
|
package/dist/ingest.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// The ingest helper: assigning ids, serialising, splitting into batches,
|
|
2
|
+
// sending them one at a time, and combining the outcomes.
|
|
3
|
+
import { isDecimalText } from "./decimal.js";
|
|
4
|
+
import { IngestError } from "./errors.js";
|
|
5
|
+
import { uuidv4 } from "./uuid.js";
|
|
6
|
+
/** The most events one batch holds. */
|
|
7
|
+
export const MAX_BATCH_EVENTS = 500;
|
|
8
|
+
/** The API's body limit for a batch, in bytes, inclusive. */
|
|
9
|
+
export const MAX_BODY_BYTES = 4_000_000;
|
|
10
|
+
/** The bytes of `{"events":[` and `]}` around a batch's events. */
|
|
11
|
+
const ENVELOPE_BYTES = 13;
|
|
12
|
+
/** The largest event that fits in a batch alone. */
|
|
13
|
+
export const MAX_EVENT_BYTES = MAX_BODY_BYTES - ENVELOPE_BYTES;
|
|
14
|
+
const encoder = new TextEncoder();
|
|
15
|
+
const PREFIX = encoder.encode('{"events":[');
|
|
16
|
+
const SUFFIX = encoder.encode("]}");
|
|
17
|
+
const COMMA = 0x2c;
|
|
18
|
+
// A UTF-16 surrogate without its partner, which UTF-8 cannot encode.
|
|
19
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
20
|
+
function checkText(text, index) {
|
|
21
|
+
if (LONE_SURROGATE.test(text)) {
|
|
22
|
+
throw new TypeError(`allisall: event ${index} holds text that is not valid Unicode (a lone surrogate)`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** `JSON.stringify`, refusing text that is not valid Unicode. */
|
|
26
|
+
function stringify(value, index) {
|
|
27
|
+
const text = JSON.stringify(value, (key, inner) => {
|
|
28
|
+
checkText(key, index);
|
|
29
|
+
if (typeof inner === "string")
|
|
30
|
+
checkText(inner, index);
|
|
31
|
+
return inner;
|
|
32
|
+
});
|
|
33
|
+
return text;
|
|
34
|
+
}
|
|
35
|
+
const NUMERIC_MEMBERS = new Set([
|
|
36
|
+
"quantity",
|
|
37
|
+
"cost",
|
|
38
|
+
"tokens_in",
|
|
39
|
+
"tokens_out",
|
|
40
|
+
]);
|
|
41
|
+
function encodeMember(name, value, index) {
|
|
42
|
+
if (typeof value === "number" && NUMERIC_MEMBERS.has(name)) {
|
|
43
|
+
if (!Number.isFinite(value)) {
|
|
44
|
+
throw new TypeError(`allisall: event ${index} has ${name} ${value}; it must be a finite number`);
|
|
45
|
+
}
|
|
46
|
+
return JSON.stringify(value);
|
|
47
|
+
}
|
|
48
|
+
if (name === "cost" && typeof value === "string") {
|
|
49
|
+
// A Decimal is written unquoted, so its syntax is checked again here:
|
|
50
|
+
// an unbranded string from untyped code must not inject JSON.
|
|
51
|
+
if (!isDecimalText(value)) {
|
|
52
|
+
throw new TypeError(`allisall: event ${index} has cost ${JSON.stringify(value)}, which is not a decimal; build it with decimal()`);
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
if (name === "timestamp" && value instanceof Date) {
|
|
57
|
+
if (Number.isNaN(value.getTime())) {
|
|
58
|
+
throw new TypeError(`allisall: event ${index} has an invalid Date as its timestamp`);
|
|
59
|
+
}
|
|
60
|
+
return JSON.stringify(value.toISOString());
|
|
61
|
+
}
|
|
62
|
+
return stringify(value, index);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Encodes one event as compact JSON. Every member the object holds is
|
|
66
|
+
* written, in its own order; a member set to `undefined` is omitted, as
|
|
67
|
+
* `JSON.stringify` omits it.
|
|
68
|
+
*/
|
|
69
|
+
export function encodeEvent(event, index) {
|
|
70
|
+
const members = [];
|
|
71
|
+
for (const [name, value] of Object.entries(event)) {
|
|
72
|
+
if (value === undefined)
|
|
73
|
+
continue;
|
|
74
|
+
const text = encodeMember(name, value, index);
|
|
75
|
+
if (text === undefined)
|
|
76
|
+
continue;
|
|
77
|
+
members.push(`${stringify(name, index)}:${text}`);
|
|
78
|
+
}
|
|
79
|
+
return `{${members.join(",")}}`;
|
|
80
|
+
}
|
|
81
|
+
function needsId(id) {
|
|
82
|
+
return id === undefined || id === null || id === "";
|
|
83
|
+
}
|
|
84
|
+
function prepare(events) {
|
|
85
|
+
return events.map((original, index) => {
|
|
86
|
+
if (typeof original !== "object" ||
|
|
87
|
+
original === null ||
|
|
88
|
+
Array.isArray(original)) {
|
|
89
|
+
throw new TypeError(`allisall: event ${index} is not an object`);
|
|
90
|
+
}
|
|
91
|
+
const event = { ...original };
|
|
92
|
+
if (needsId(event.event_id))
|
|
93
|
+
event.event_id = uuidv4();
|
|
94
|
+
return { index, event, bytes: encoder.encode(encodeEvent(event, index)) };
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function tooLarge(p) {
|
|
98
|
+
return {
|
|
99
|
+
index: p.index,
|
|
100
|
+
code: "too_large",
|
|
101
|
+
pointer: `/events/${p.index}`,
|
|
102
|
+
detail: `The event serialises to ${p.bytes.length} bytes and was not sent. ` +
|
|
103
|
+
`The SDK sends an event of at most ${MAX_EVENT_BYTES} bytes, so that a batch holding it alone ` +
|
|
104
|
+
`stays within the API's body limit of ${MAX_BODY_BYTES} bytes.`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Packs events into batches in input order. A batch closes at 500 events,
|
|
109
|
+
* or before an event that would take its body over 4,000,000 bytes. An
|
|
110
|
+
* event too large to send alone joins no batch and becomes a row error.
|
|
111
|
+
*/
|
|
112
|
+
function split(prepared) {
|
|
113
|
+
const batches = [];
|
|
114
|
+
const rejected = [];
|
|
115
|
+
let current;
|
|
116
|
+
for (const p of prepared) {
|
|
117
|
+
const size = p.bytes.length;
|
|
118
|
+
if (size > MAX_EVENT_BYTES) {
|
|
119
|
+
rejected.push(tooLarge(p));
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (current !== undefined &&
|
|
123
|
+
(current.events.length >= MAX_BATCH_EVENTS ||
|
|
124
|
+
current.size + 1 + size > MAX_BODY_BYTES)) {
|
|
125
|
+
current = undefined;
|
|
126
|
+
}
|
|
127
|
+
if (current === undefined) {
|
|
128
|
+
current = { events: [p], size: ENVELOPE_BYTES + size };
|
|
129
|
+
batches.push(current);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
current.events.push(p);
|
|
133
|
+
current.size += 1 + size;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { batches, rejected };
|
|
137
|
+
}
|
|
138
|
+
/** Writes a batch as `{"events":[e1,e2,…,en]}`, reusing each event's bytes. */
|
|
139
|
+
function body(batch) {
|
|
140
|
+
const out = new Uint8Array(batch.size);
|
|
141
|
+
out.set(PREFIX, 0);
|
|
142
|
+
let at = PREFIX.length;
|
|
143
|
+
batch.events.forEach((p, i) => {
|
|
144
|
+
if (i > 0)
|
|
145
|
+
out[at++] = COMMA;
|
|
146
|
+
out.set(p.bytes, at);
|
|
147
|
+
at += p.bytes.length;
|
|
148
|
+
});
|
|
149
|
+
out.set(SUFFIX, at);
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
const POINTER_ROW = /^\/events\/(\d+)(?=\/|$)/;
|
|
153
|
+
/** Rewrites a batch's row error to the caller's list. */
|
|
154
|
+
function rewrite(error, batch) {
|
|
155
|
+
const toCaller = (i) => batch.events[i]?.index ?? i;
|
|
156
|
+
const pointer = error.pointer.replace(POINTER_ROW, (_, row) => `/events/${toCaller(Number(row))}`);
|
|
157
|
+
return { ...error, index: toCaller(error.index), pointer };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Reads a `2xx` ingest body for a batch of `count` events as an
|
|
161
|
+
* `IngestResult`, or `undefined` when it is not one. A row error whose
|
|
162
|
+
* `index` is not a position in the batch makes the body unreadable, as a
|
|
163
|
+
* body of the wrong shape is.
|
|
164
|
+
*/
|
|
165
|
+
export function readIngestResult(value, count) {
|
|
166
|
+
if (typeof value !== "object" || value === null)
|
|
167
|
+
return undefined;
|
|
168
|
+
const accepted = Reflect.get(value, "accepted");
|
|
169
|
+
const duplicates = Reflect.get(value, "duplicates");
|
|
170
|
+
const rejected = Reflect.get(value, "rejected");
|
|
171
|
+
const errors = Reflect.get(value, "errors");
|
|
172
|
+
const truncated = Reflect.get(value, "errors_truncated");
|
|
173
|
+
if (typeof accepted !== "number" ||
|
|
174
|
+
typeof duplicates !== "number" ||
|
|
175
|
+
typeof rejected !== "number" ||
|
|
176
|
+
typeof truncated !== "boolean" ||
|
|
177
|
+
!Array.isArray(errors)) {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
const rows = [];
|
|
181
|
+
for (const e of errors) {
|
|
182
|
+
if (typeof e !== "object" || e === null)
|
|
183
|
+
return undefined;
|
|
184
|
+
const index = Reflect.get(e, "index");
|
|
185
|
+
const code = Reflect.get(e, "code");
|
|
186
|
+
const pointer = Reflect.get(e, "pointer");
|
|
187
|
+
const detail = Reflect.get(e, "detail");
|
|
188
|
+
if (typeof index !== "number" ||
|
|
189
|
+
typeof code !== "string" ||
|
|
190
|
+
typeof pointer !== "string" ||
|
|
191
|
+
typeof detail !== "string") {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
if (!Number.isInteger(index) || index < 0 || index >= count) {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
rows.push({ index, code, pointer, detail });
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
accepted,
|
|
201
|
+
duplicates,
|
|
202
|
+
rejected,
|
|
203
|
+
errors: rows,
|
|
204
|
+
errors_truncated: truncated,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Sends any number of events in as many batches as the limits require,
|
|
209
|
+
* one after another, and combines their outcomes. Stops at the first batch
|
|
210
|
+
* that fails, throwing an `IngestError`.
|
|
211
|
+
*/
|
|
212
|
+
export async function ingest(events, send, signal) {
|
|
213
|
+
const { batches, rejected } = split(prepare(events));
|
|
214
|
+
const summary = {
|
|
215
|
+
accepted: 0,
|
|
216
|
+
duplicates: 0,
|
|
217
|
+
rejected: rejected.length,
|
|
218
|
+
errors: [...rejected],
|
|
219
|
+
errors_truncated: false,
|
|
220
|
+
batches: 0,
|
|
221
|
+
};
|
|
222
|
+
const finish = () => {
|
|
223
|
+
// Stable, so a row's entries keep the order the API gave them.
|
|
224
|
+
summary.errors.sort((a, b) => a.index - b.index);
|
|
225
|
+
return summary;
|
|
226
|
+
};
|
|
227
|
+
for (const [i, batch] of batches.entries()) {
|
|
228
|
+
let result;
|
|
229
|
+
try {
|
|
230
|
+
result = await send(body(batch), batch.events.length, signal);
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
// Every failure of a batch is wrapped, the token provider's included,
|
|
234
|
+
// so the caller keeps the summary and the generated ids in remaining.
|
|
235
|
+
const remaining = batches.slice(i).flatMap((b) => b.events);
|
|
236
|
+
throw new IngestError(err, finish(), remaining.map((p) => p.event), remaining[0]?.index ?? 0);
|
|
237
|
+
}
|
|
238
|
+
summary.accepted += result.accepted;
|
|
239
|
+
summary.duplicates += result.duplicates;
|
|
240
|
+
summary.rejected += result.rejected;
|
|
241
|
+
summary.errors_truncated ||= result.errors_truncated;
|
|
242
|
+
for (const error of result.errors)
|
|
243
|
+
summary.errors.push(rewrite(error, batch));
|
|
244
|
+
summary.batches++;
|
|
245
|
+
}
|
|
246
|
+
return finish();
|
|
247
|
+
}
|
package/dist/money.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type Decimal } from "./decimal.js";
|
|
2
|
+
import { type JsonPath } from "./generated/operations.js";
|
|
3
|
+
/**
|
|
4
|
+
* A request type with every `Decimal` in it widened to `Decimal | number`:
|
|
5
|
+
* what a request accepts where the API takes money. Use it to type a body
|
|
6
|
+
* you build before passing it to a method, such as
|
|
7
|
+
* `MoneyInput<CostLineItemCreate>`.
|
|
8
|
+
*
|
|
9
|
+
* A `Decimal`, such as one read from an earlier response, is sent with its
|
|
10
|
+
* own digits. A `number` is sent as its shortest round-trip text, so a
|
|
11
|
+
* literal such as `12.5` is sent exactly; a computed float such as
|
|
12
|
+
* `0.1 + 0.2` is sent as `0.30000000000000004`, which the API refuses as
|
|
13
|
+
* too precise rather than storing it inexactly. Build a `Decimal` with
|
|
14
|
+
* `decimal()` when the amount must be exact.
|
|
15
|
+
*/
|
|
16
|
+
export type MoneyInput<T> = T extends Decimal ? Decimal | number : T extends Date ? T : T extends (infer U)[] ? MoneyInput<U>[] : T extends object ? {
|
|
17
|
+
[K in keyof T]: MoneyInput<T[K]>;
|
|
18
|
+
} : T;
|
|
19
|
+
/**
|
|
20
|
+
* The largest exponent magnitude the decoder writes out in digits. Money is
|
|
21
|
+
* far smaller; the bound keeps a hostile exponent from building a huge
|
|
22
|
+
* string.
|
|
23
|
+
*/
|
|
24
|
+
export declare const MAX_EXPONENT = 400;
|
|
25
|
+
/**
|
|
26
|
+
* The text of a `Decimal` equal to a JSON number's text. A number in
|
|
27
|
+
* exponent form, such as `1e-7`, is written out in digits; the conversion
|
|
28
|
+
* moves the decimal point and never rounds.
|
|
29
|
+
*
|
|
30
|
+
* @throws {AllisallError} when the exponent's magnitude exceeds
|
|
31
|
+
* `MAX_EXPONENT`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function decimalOfJsonNumber(text: string): Decimal | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Parses a response body. Every number at one of `moneyPaths` becomes a
|
|
36
|
+
* `Decimal` of the number's text; every other number becomes the `number`
|
|
37
|
+
* `JSON.parse` would give. A `null` stays `null`.
|
|
38
|
+
*
|
|
39
|
+
* @throws {SyntaxError} when `text` is not JSON.
|
|
40
|
+
* @throws {AllisallError} when the body holds money and the runtime does not
|
|
41
|
+
* give a reviver a number's text.
|
|
42
|
+
*/
|
|
43
|
+
export declare function decodeJson(text: string, moneyPaths: readonly JsonPath[]): unknown;
|
|
44
|
+
/**
|
|
45
|
+
* Serialises a request body. At each of `moneyPaths`, a `Decimal` is
|
|
46
|
+
* written as its own digits and a `number` as its shortest round-trip text.
|
|
47
|
+
* The caller's object is not changed. Every member it holds is written, as
|
|
48
|
+
* `JSON.stringify` writes it.
|
|
49
|
+
*
|
|
50
|
+
* @throws {TypeError} when a value at a money path is not a `Decimal`, a
|
|
51
|
+
* finite number or `null`; when any value is an invalid `Date`, a
|
|
52
|
+
* non-finite number, or text holding a lone surrogate, naming its path; or
|
|
53
|
+
* when the body has no JSON text, such as `undefined`.
|
|
54
|
+
* @throws {AllisallError} when the body holds a `Decimal` and the runtime has
|
|
55
|
+
* no `JSON.rawJSON`.
|
|
56
|
+
*/
|
|
57
|
+
export declare function encodeJson(body: unknown, moneyPaths: readonly JsonPath[]): string;
|
package/dist/money.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Exact money in JSON bodies. A `Money` value is a JSON number on the wire.
|
|
2
|
+
// The SDK never lets a binary float hold one: a response's money is decoded
|
|
3
|
+
// from the number's own text into a `Decimal`, and a request's `Decimal` is
|
|
4
|
+
// written as its own text.
|
|
5
|
+
//
|
|
6
|
+
// Both directions work from an operation's `Money` paths, from the operation
|
|
7
|
+
// table. A path, not a member's name, decides whether a number is money:
|
|
8
|
+
// `total` is money in one schema and a count in another.
|
|
9
|
+
//
|
|
10
|
+
// Two platform features make this possible, and TypeScript's own library
|
|
11
|
+
// files declare neither, so this module declares the shapes it reads:
|
|
12
|
+
//
|
|
13
|
+
// - The third argument a `JSON.parse` reviver receives, whose `source` is a
|
|
14
|
+
// number's text as it appeared in the JSON.
|
|
15
|
+
// - `JSON.rawJSON(text)`, whose result `JSON.stringify` writes as `text`,
|
|
16
|
+
// unquoted.
|
|
17
|
+
//
|
|
18
|
+
// Both arrived in Chrome 114, Firefox 135, Safari 18.4 and Node 21. Without
|
|
19
|
+
// them, money cannot be handled exactly, and the SDK refuses rather than
|
|
20
|
+
// fall back on a float.
|
|
21
|
+
import { isDecimalText } from "./decimal.js";
|
|
22
|
+
import { AllisallError } from "./errors.js";
|
|
23
|
+
import { ITEMS } from "./generated/operations.js";
|
|
24
|
+
import { hasLoneSurrogate } from "./unicode.js";
|
|
25
|
+
const trees = new WeakMap();
|
|
26
|
+
function treeOf(paths) {
|
|
27
|
+
const cached = trees.get(paths);
|
|
28
|
+
if (cached !== undefined)
|
|
29
|
+
return cached;
|
|
30
|
+
const root = { money: false, children: new Map() };
|
|
31
|
+
for (const path of paths) {
|
|
32
|
+
let node = root;
|
|
33
|
+
for (const segment of path) {
|
|
34
|
+
let child = node.children.get(segment);
|
|
35
|
+
if (child === undefined) {
|
|
36
|
+
child = { money: false, children: new Map() };
|
|
37
|
+
node.children.set(segment, child);
|
|
38
|
+
}
|
|
39
|
+
node = child;
|
|
40
|
+
}
|
|
41
|
+
node.money = true;
|
|
42
|
+
}
|
|
43
|
+
trees.set(paths, root);
|
|
44
|
+
return root;
|
|
45
|
+
}
|
|
46
|
+
function childOf(node, segment) {
|
|
47
|
+
return node?.children.get(segment);
|
|
48
|
+
}
|
|
49
|
+
function isPlainObject(value) {
|
|
50
|
+
if (typeof value !== "object" || value === null)
|
|
51
|
+
return false;
|
|
52
|
+
const proto = Object.getPrototypeOf(value);
|
|
53
|
+
return proto === Object.prototype || proto === null;
|
|
54
|
+
}
|
|
55
|
+
// ---- Decoding a response ----
|
|
56
|
+
/**
|
|
57
|
+
* A JSON number as the first pass of decoding leaves it: its value, and its
|
|
58
|
+
* text when the runtime supplied it.
|
|
59
|
+
*/
|
|
60
|
+
class JsonNumber {
|
|
61
|
+
value;
|
|
62
|
+
source;
|
|
63
|
+
constructor(value, source) {
|
|
64
|
+
this.value = value;
|
|
65
|
+
this.source = source;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** The text of the number a reviver was called with, when the runtime supplies it. */
|
|
69
|
+
function sourceText(context) {
|
|
70
|
+
if (typeof context !== "object" || context === null)
|
|
71
|
+
return undefined;
|
|
72
|
+
const source = Reflect.get(context, "source");
|
|
73
|
+
return typeof source === "string" ? source : undefined;
|
|
74
|
+
}
|
|
75
|
+
function keepNumberText(_key, value, context) {
|
|
76
|
+
return typeof value === "number"
|
|
77
|
+
? new JsonNumber(value, sourceText(context))
|
|
78
|
+
: value;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The largest exponent magnitude the decoder writes out in digits. Money is
|
|
82
|
+
* far smaller; the bound keeps a hostile exponent from building a huge
|
|
83
|
+
* string.
|
|
84
|
+
*/
|
|
85
|
+
export const MAX_EXPONENT = 400;
|
|
86
|
+
const JSON_NUMBER = /^(-?)(0|[1-9][0-9]*)(?:\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?$/;
|
|
87
|
+
/**
|
|
88
|
+
* The text of a `Decimal` equal to a JSON number's text. A number in
|
|
89
|
+
* exponent form, such as `1e-7`, is written out in digits; the conversion
|
|
90
|
+
* moves the decimal point and never rounds.
|
|
91
|
+
*
|
|
92
|
+
* @throws {AllisallError} when the exponent's magnitude exceeds
|
|
93
|
+
* `MAX_EXPONENT`.
|
|
94
|
+
*/
|
|
95
|
+
export function decimalOfJsonNumber(text) {
|
|
96
|
+
if (isDecimalText(text)) {
|
|
97
|
+
// The syntax check is the invariant the brand stands for.
|
|
98
|
+
return text;
|
|
99
|
+
}
|
|
100
|
+
const match = JSON_NUMBER.exec(text);
|
|
101
|
+
if (match === null)
|
|
102
|
+
return undefined;
|
|
103
|
+
const [, sign = "", whole = "", fraction = "", exponent = "0"] = match;
|
|
104
|
+
const digits = whole + fraction;
|
|
105
|
+
const shift = Number(exponent);
|
|
106
|
+
if (!(Math.abs(shift) <= MAX_EXPONENT)) {
|
|
107
|
+
throw new AllisallError(`allisall: the API sent money as ${JSON.stringify(text)}, whose exponent is beyond ±${MAX_EXPONENT}`);
|
|
108
|
+
}
|
|
109
|
+
const point = whole.length + shift;
|
|
110
|
+
let out;
|
|
111
|
+
if (point <= 0)
|
|
112
|
+
out = `0.${"0".repeat(-point)}${digits}`;
|
|
113
|
+
else if (point >= digits.length)
|
|
114
|
+
out = digits + "0".repeat(point - digits.length);
|
|
115
|
+
else
|
|
116
|
+
out = `${digits.slice(0, point)}.${digits.slice(point)}`;
|
|
117
|
+
// Moving the point can leave leading zeros in the whole part, as `0.5e1`
|
|
118
|
+
// becomes `05`.
|
|
119
|
+
out = out.replace(/^0+(?=[0-9])/, "");
|
|
120
|
+
const result = sign + out;
|
|
121
|
+
// Every branch above writes decimal text; the check keeps the brand honest.
|
|
122
|
+
return isDecimalText(result) ? result : undefined;
|
|
123
|
+
}
|
|
124
|
+
function place(value, node) {
|
|
125
|
+
if (value instanceof JsonNumber) {
|
|
126
|
+
if (node?.money !== true)
|
|
127
|
+
return value.value;
|
|
128
|
+
if (value.source === undefined) {
|
|
129
|
+
throw new AllisallError("allisall: this runtime does not give a JSON.parse reviver the source text of a number (the reviver's context argument), so the money in this response cannot be read exactly; use Node 21 or later, Chrome 114, Firefox 135 or Safari 18.4 or later");
|
|
130
|
+
}
|
|
131
|
+
const amount = decimalOfJsonNumber(value.source);
|
|
132
|
+
if (amount === undefined) {
|
|
133
|
+
throw new AllisallError(`allisall: the API sent money as ${JSON.stringify(value.source)}, which is not a decimal number`);
|
|
134
|
+
}
|
|
135
|
+
return amount;
|
|
136
|
+
}
|
|
137
|
+
if (Array.isArray(value)) {
|
|
138
|
+
const items = childOf(node, ITEMS);
|
|
139
|
+
for (let i = 0; i < value.length; i++)
|
|
140
|
+
value[i] = place(value[i], items);
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
if (typeof value === "object" && value !== null) {
|
|
144
|
+
for (const key of Object.keys(value)) {
|
|
145
|
+
Reflect.set(value, key, place(Reflect.get(value, key), childOf(node, key)));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Parses a response body. Every number at one of `moneyPaths` becomes a
|
|
152
|
+
* `Decimal` of the number's text; every other number becomes the `number`
|
|
153
|
+
* `JSON.parse` would give. A `null` stays `null`.
|
|
154
|
+
*
|
|
155
|
+
* @throws {SyntaxError} when `text` is not JSON.
|
|
156
|
+
* @throws {AllisallError} when the body holds money and the runtime does not
|
|
157
|
+
* give a reviver a number's text.
|
|
158
|
+
*/
|
|
159
|
+
export function decodeJson(text, moneyPaths) {
|
|
160
|
+
const parsed = JSON.parse(text, keepNumberText);
|
|
161
|
+
return place(parsed, treeOf(moneyPaths));
|
|
162
|
+
}
|
|
163
|
+
// ---- Encoding a request ----
|
|
164
|
+
/** `JSON.rawJSON`, when the runtime has it. */
|
|
165
|
+
function rawJson() {
|
|
166
|
+
const fn = Reflect.get(JSON, "rawJSON");
|
|
167
|
+
if (typeof fn !== "function")
|
|
168
|
+
return undefined;
|
|
169
|
+
return (text) => Reflect.apply(fn, JSON, [text]);
|
|
170
|
+
}
|
|
171
|
+
function kindOf(value) {
|
|
172
|
+
if (Array.isArray(value))
|
|
173
|
+
return "an array";
|
|
174
|
+
return typeof value === "object" ? "an object" : `a ${typeof value}`;
|
|
175
|
+
}
|
|
176
|
+
function encodeMoney(value, path) {
|
|
177
|
+
const where = path.length === 0 ? "the body" : `/${path.join("/")}`;
|
|
178
|
+
if (typeof value === "number") {
|
|
179
|
+
if (!Number.isFinite(value)) {
|
|
180
|
+
throw new TypeError(`allisall: the money at ${where} is ${value}; it must be a finite number`);
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
// A money member may be nullable; an absent or undefined one is not sent.
|
|
185
|
+
if (value === null || value === undefined)
|
|
186
|
+
return value;
|
|
187
|
+
if (typeof value !== "string") {
|
|
188
|
+
throw new TypeError(`allisall: the money at ${where} is ${kindOf(value)}; it must be a Decimal, a finite number or null`);
|
|
189
|
+
}
|
|
190
|
+
if (!isDecimalText(value)) {
|
|
191
|
+
throw new TypeError(`allisall: the money at ${where} is ${JSON.stringify(value)}, which is not a decimal; build it with decimal()`);
|
|
192
|
+
}
|
|
193
|
+
const raw = rawJson();
|
|
194
|
+
if (raw === undefined) {
|
|
195
|
+
throw new AllisallError("allisall: this runtime has no JSON.rawJSON, so a Decimal cannot be sent exactly; use Node 21 or later, Chrome 114, Firefox 135 or Safari 18.4 or later");
|
|
196
|
+
}
|
|
197
|
+
return raw(value);
|
|
198
|
+
}
|
|
199
|
+
function copyAlong(value, node, path) {
|
|
200
|
+
if (node.money)
|
|
201
|
+
return encodeMoney(value, path);
|
|
202
|
+
if (Array.isArray(value)) {
|
|
203
|
+
const items = node.children.get(ITEMS);
|
|
204
|
+
if (items === undefined)
|
|
205
|
+
return value;
|
|
206
|
+
return value.map((item, i) => copyAlong(item, items, [...path, String(i)]));
|
|
207
|
+
}
|
|
208
|
+
if (isPlainObject(value)) {
|
|
209
|
+
const copy = {};
|
|
210
|
+
for (const [key, member] of Object.entries(value)) {
|
|
211
|
+
const child = node.children.get(key);
|
|
212
|
+
const written = child === undefined ? member : copyAlong(member, child, [...path, key]);
|
|
213
|
+
Object.defineProperty(copy, key, {
|
|
214
|
+
value: written,
|
|
215
|
+
enumerable: true,
|
|
216
|
+
writable: true,
|
|
217
|
+
configurable: true,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return copy;
|
|
221
|
+
}
|
|
222
|
+
return value;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* A `JSON.stringify` replacer refusing the values it would write as `null`
|
|
226
|
+
* in silence: an invalid `Date`, and a non-finite number. On a merge patch,
|
|
227
|
+
* `null` clears a member, so either would change data the caller never
|
|
228
|
+
* meant to touch. It also refuses text holding a lone surrogate, in a
|
|
229
|
+
* string or a member name, which the API would store as U+FFFD.
|
|
230
|
+
*
|
|
231
|
+
* A replacer sees a member's key and its holder, not its path, so each
|
|
232
|
+
* object's path is recorded as it is reached, before its members are.
|
|
233
|
+
*/
|
|
234
|
+
function refuseLostValues() {
|
|
235
|
+
const paths = new WeakMap();
|
|
236
|
+
return function (key, value) {
|
|
237
|
+
const holder = typeof this === "object" && this !== null ? this : undefined;
|
|
238
|
+
const parent = holder === undefined ? undefined : paths.get(holder);
|
|
239
|
+
// The root's holder is the wrapper JSON.stringify makes, never recorded.
|
|
240
|
+
// A name is checked before it joins a path, so no message carries it.
|
|
241
|
+
if (parent !== undefined && hasLoneSurrogate(key)) {
|
|
242
|
+
throw new TypeError(`allisall: a member name in the object at ${parent === "" ? "the body" : parent} is not valid Unicode (a lone surrogate)`);
|
|
243
|
+
}
|
|
244
|
+
const path = parent === undefined ? "" : `${parent}/${key}`;
|
|
245
|
+
const where = path === "" ? "the body" : path;
|
|
246
|
+
// The value has been through toJSON already; the holder keeps the Date.
|
|
247
|
+
const original = holder === undefined ? value : Reflect.get(holder, key);
|
|
248
|
+
if (original instanceof Date && Number.isNaN(original.getTime())) {
|
|
249
|
+
throw new TypeError(`allisall: the value at ${where} is an invalid Date`);
|
|
250
|
+
}
|
|
251
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
252
|
+
throw new TypeError(`allisall: the value at ${where} is ${value}; it must be a finite number`);
|
|
253
|
+
}
|
|
254
|
+
if (typeof value === "string" && hasLoneSurrogate(value)) {
|
|
255
|
+
throw new TypeError(`allisall: the value at ${where} is not valid Unicode (a lone surrogate)`);
|
|
256
|
+
}
|
|
257
|
+
if (typeof value === "object" && value !== null)
|
|
258
|
+
paths.set(value, path);
|
|
259
|
+
return value;
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Serialises a request body. At each of `moneyPaths`, a `Decimal` is
|
|
264
|
+
* written as its own digits and a `number` as its shortest round-trip text.
|
|
265
|
+
* The caller's object is not changed. Every member it holds is written, as
|
|
266
|
+
* `JSON.stringify` writes it.
|
|
267
|
+
*
|
|
268
|
+
* @throws {TypeError} when a value at a money path is not a `Decimal`, a
|
|
269
|
+
* finite number or `null`; when any value is an invalid `Date`, a
|
|
270
|
+
* non-finite number, or text holding a lone surrogate, naming its path; or
|
|
271
|
+
* when the body has no JSON text, such as `undefined`.
|
|
272
|
+
* @throws {AllisallError} when the body holds a `Decimal` and the runtime has
|
|
273
|
+
* no `JSON.rawJSON`.
|
|
274
|
+
*/
|
|
275
|
+
export function encodeJson(body, moneyPaths) {
|
|
276
|
+
const copy = copyAlong(body, treeOf(moneyPaths), []);
|
|
277
|
+
// JSON.stringify gives undefined, despite its declared type, for a body
|
|
278
|
+
// with no JSON text: undefined, a function or a symbol.
|
|
279
|
+
const text = JSON.stringify(copy, refuseLostValues());
|
|
280
|
+
if (typeof text !== "string") {
|
|
281
|
+
throw new TypeError(`allisall: a request body of type ${typeof body} has no JSON text`);
|
|
282
|
+
}
|
|
283
|
+
return text;
|
|
284
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { components } from "./generated/openapi.js";
|
|
2
|
+
/** A CSV upload: the mapping of its columns, and the file itself. */
|
|
3
|
+
export interface ImportUpload {
|
|
4
|
+
/** How the file's columns map to event members, sent as the first part. */
|
|
5
|
+
mapping: components["schemas"]["ImportMapping"];
|
|
6
|
+
/**
|
|
7
|
+
* The CSV file, sent as the second part with the type `text/csv`. A
|
|
8
|
+
* `Blob` is sent without being read into memory first; in Node,
|
|
9
|
+
* `fs.openAsBlob(path)` gives one for a file on disk. A stream is not
|
|
10
|
+
* accepted, because a retry must send the same bytes again.
|
|
11
|
+
*/
|
|
12
|
+
file: Blob | Uint8Array | string;
|
|
13
|
+
/**
|
|
14
|
+
* The file name the import records. Defaults to the name of a `File`, and
|
|
15
|
+
* otherwise to `upload.csv`; an empty name counts as none.
|
|
16
|
+
*/
|
|
17
|
+
filename?: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Builds the form once, `mapping` before `file`, as the API reads them. The
|
|
21
|
+
* same form is the body of every attempt.
|
|
22
|
+
*
|
|
23
|
+
* @throws {TypeError} when the upload is not an object, the mapping has no
|
|
24
|
+
* JSON text, or the file is not a `Blob`, a `Uint8Array` or a string, or
|
|
25
|
+
* is a string holding a lone surrogate.
|
|
26
|
+
*/
|
|
27
|
+
export declare function formOf(upload: ImportUpload): FormData;
|