@37signals/basecamp 0.18.0 → 0.20.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/README.md +94 -2
- package/dist/client.d.ts +6 -6
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +11 -7
- package/dist/client.js.map +1 -1
- package/dist/download.js +1 -1
- package/dist/errors.d.ts +10 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +42 -13
- package/dist/errors.js.map +1 -1
- package/dist/generated/metadata.d.ts +1 -2
- package/dist/generated/metadata.d.ts.map +1 -1
- package/dist/generated/metadata.js +47 -1
- package/dist/generated/metadata.js.map +1 -1
- package/dist/generated/metadata.ts +48 -3
- package/dist/generated/openapi-stripped.json +816 -60
- package/dist/generated/path-mapping.d.ts.map +1 -1
- package/dist/generated/path-mapping.js +7 -4
- package/dist/generated/path-mapping.js.map +1 -1
- package/dist/generated/path-mapping.ts +8 -5
- package/dist/generated/person-id-sites.d.ts +2 -0
- package/dist/generated/person-id-sites.d.ts.map +1 -0
- package/dist/generated/person-id-sites.js +689 -0
- package/dist/generated/person-id-sites.js.map +1 -0
- package/dist/generated/person-id-sites.ts +690 -0
- package/dist/generated/schema.d.ts +643 -12
- package/dist/generated/services/event-feed.d.ts +92 -0
- package/dist/generated/services/event-feed.d.ts.map +1 -0
- package/dist/generated/services/event-feed.js +80 -0
- package/dist/generated/services/event-feed.js.map +1 -0
- package/dist/generated/services/event-feed.ts +153 -0
- package/dist/generated/services/gauges.d.ts +7 -3
- package/dist/generated/services/gauges.d.ts.map +1 -1
- package/dist/generated/services/gauges.js +4 -1
- package/dist/generated/services/gauges.js.map +1 -1
- package/dist/generated/services/gauges.ts +10 -3
- package/dist/generated/services/index.d.ts +1 -1
- package/dist/generated/services/index.d.ts.map +1 -1
- package/dist/generated/services/index.js +1 -1
- package/dist/generated/services/index.js.map +1 -1
- package/dist/generated/services/index.ts +1 -1
- package/dist/generated/services/lineup.d.ts +13 -0
- package/dist/generated/services/lineup.d.ts.map +1 -1
- package/dist/generated/services/lineup.js +18 -0
- package/dist/generated/services/lineup.js.map +1 -1
- package/dist/generated/services/lineup.ts +26 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -6
- package/dist/index.js.map +1 -1
- package/dist/person-id.d.ts +133 -0
- package/dist/person-id.d.ts.map +1 -0
- package/dist/person-id.js +163 -0
- package/dist/person-id.js.map +1 -0
- package/dist/retry.d.ts +2 -1
- package/dist/retry.d.ts.map +1 -1
- package/dist/retry.js +21 -11
- package/dist/retry.js.map +1 -1
- package/dist/services/base.d.ts +35 -0
- package/dist/services/base.d.ts.map +1 -1
- package/dist/services/base.js +387 -55
- package/dist/services/base.js.map +1 -1
- package/dist/services/comments-extensions.d.ts +89 -0
- package/dist/services/comments-extensions.d.ts.map +1 -0
- package/dist/services/comments-extensions.js +166 -0
- package/dist/services/comments-extensions.js.map +1 -0
- package/dist/services/mentions.d.ts +255 -0
- package/dist/services/mentions.d.ts.map +1 -0
- package/dist/services/mentions.js +1431 -0
- package/dist/services/mentions.js.map +1 -0
- package/dist/services/merge-safe.d.ts +13 -5
- package/dist/services/merge-safe.d.ts.map +1 -1
- package/dist/services/merge-safe.js +71 -13
- package/dist/services/merge-safe.js.map +1 -1
- package/dist/services/recordings-extensions.d.ts +354 -0
- package/dist/services/recordings-extensions.d.ts.map +1 -0
- package/dist/services/recordings-extensions.js +1940 -0
- package/dist/services/recordings-extensions.js.map +1 -0
- package/package.json +4 -4
- package/src/generated/metadata.ts +48 -3
- package/src/generated/openapi-stripped.json +816 -60
- package/src/generated/path-mapping.ts +8 -5
- package/src/generated/person-id-sites.ts +690 -0
- package/src/generated/schema.d.ts +643 -12
- package/src/generated/services/event-feed.ts +153 -0
- package/src/generated/services/gauges.ts +10 -3
- package/src/generated/services/index.ts +1 -1
- package/src/generated/services/lineup.ts +26 -0
- package/dist/generated/services/automation.d.ts +0 -25
- package/dist/generated/services/automation.d.ts.map +0 -1
- package/dist/generated/services/automation.js +0 -33
- package/dist/generated/services/automation.js.map +0 -1
- package/dist/generated/services/automation.ts +0 -49
- package/src/generated/services/automation.ts +0 -49
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"base.d.ts","sourceRoot":"","sources":["../../src/services/base.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAmB,MAAM,aAAa,CAAC;AACjF,OAAO,EAAE,aAAa,EAAiF,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"base.d.ts","sourceRoot":"","sources":["../../src/services/base.ts"],"names":[],"mappings":"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"}
|
package/dist/services/base.js
CHANGED
|
@@ -23,9 +23,12 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { BasecampError, errorFromParsedBody, errorFromResponse, parseRetryAfter, truncateErrorMessage } from "../errors.js";
|
|
25
25
|
import metadata from "../generated/metadata.js";
|
|
26
|
+
import { PERSON_ID_SITES } from "../generated/person-id-sites.js";
|
|
26
27
|
import { ListResult, parseTotalCount } from "../pagination.js";
|
|
28
|
+
import { personIdNumber, scanPersonId } from "../person-id.js";
|
|
27
29
|
import { parseNextLink, resolveURL, isSameOrigin, DEFAULT_MAX_PAGES, assertValidMaxPages } from "../pagination-utils.js";
|
|
28
30
|
import { saturatingBackoff, timerSafeDelayMs } from "../retry.js";
|
|
31
|
+
import { malformedResponse } from "./merge-safe.js";
|
|
29
32
|
/**
|
|
30
33
|
* True when the caller pinned a single page (SPEC section 8).
|
|
31
34
|
*
|
|
@@ -49,22 +52,114 @@ function selectedPageResult(response, items, totalCount, maxItems) {
|
|
|
49
52
|
return new ListResult(capped ? items.slice(0, maxItems) : items, { totalCount, truncated });
|
|
50
53
|
}
|
|
51
54
|
/**
|
|
52
|
-
*
|
|
55
|
+
* Rewrites one Person-shaped object's string `id` in place.
|
|
53
56
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
+
* This is `coercePersonID` (`go/pkg/basecamp/normalize.go:38-68`), outcome for
|
|
58
|
+
* outcome, over the one rule in `../person-id.js` — Go's
|
|
59
|
+
* `strconv.ParseInt(s, 10, 64)`:
|
|
60
|
+
*
|
|
61
|
+
* - it reads a number: write the number, no `system_label` (`:58`)
|
|
62
|
+
* - SYNTAX ("basecamp", " 7", "12.0", "1e3"): id `0`, the raw string kept as
|
|
63
|
+
* `system_label` (`:66-67`) — Go's non-numeric sentinel
|
|
64
|
+
* - RANGE ("9223372036854775808"): leave the string exactly as it arrived
|
|
65
|
+
* (`:62-63`), so whoever reads it refuses rather than inventing an id
|
|
66
|
+
*
|
|
67
|
+
* An object whose `id` is already a number, or absent, is left alone — which is
|
|
68
|
+
* also what makes this IDEMPOTENT, and both passes below depend on that.
|
|
69
|
+
*
|
|
70
|
+
* The `^-?\d+$` + `Number.isSafeInteger` pair this replaces was wrong at both
|
|
71
|
+
* ends. It refused the leading `+` that `ParseInt` takes, so `"+7"` — a real
|
|
72
|
+
* person — became the sentinel. And it collapsed EVERY id past
|
|
73
|
+
* `Number.MAX_SAFE_INTEGER` to `id: 0` with a `system_label`: range errors and
|
|
74
|
+
* genuine large ids alike, which is to say it turned people into LocalPerson,
|
|
75
|
+
* silently, in the shape callers trust. Measured against the reference, 26 of
|
|
76
|
+
* the 74 corpus rows in `tests/helpers/person-id-corpus.ts` disagreed with Go
|
|
77
|
+
* (25 of them on the id itself; `"+0"` agreed on `0` and added a spurious
|
|
78
|
+
* label). The 11 that remain are the unrepresentable-value rows
|
|
79
|
+
* {@link personIdNumber} argues about.
|
|
57
80
|
*/
|
|
81
|
+
function coercePersonId(rec) {
|
|
82
|
+
if (typeof rec.id !== "string")
|
|
83
|
+
return;
|
|
84
|
+
const idStr = rec.id;
|
|
85
|
+
const scan = scanPersonId(idStr);
|
|
86
|
+
if (scan.kind === "syntax") {
|
|
87
|
+
// Go's non-numeric sentinel, with the label the id came in as.
|
|
88
|
+
rec.system_label = idStr;
|
|
89
|
+
rec.id = 0;
|
|
90
|
+
}
|
|
91
|
+
else if (scan.kind === "value") {
|
|
92
|
+
const id = personIdNumber(scan.value);
|
|
93
|
+
// `undefined` is an id Go read and a `number` cannot hold. Leaving the
|
|
94
|
+
// string is the RANGE treatment, for the reason at personIdNumber: a
|
|
95
|
+
// rounded id names a different person and `0` names the system actor,
|
|
96
|
+
// while the digits, left alone, name whoever they always named.
|
|
97
|
+
if (id !== undefined)
|
|
98
|
+
rec.id = id;
|
|
99
|
+
}
|
|
100
|
+
// RANGE: nothing. The string stays put and the caller refuses it.
|
|
101
|
+
}
|
|
102
|
+
/** A JSON object — what Go's `.(map[string]any)` assertion accepts, and no array. */
|
|
103
|
+
function isJSONObject(value) {
|
|
104
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
105
|
+
}
|
|
58
106
|
/**
|
|
59
|
-
*
|
|
107
|
+
* The keys Go's positional pass finds a person under, with the shape each holds.
|
|
108
|
+
*
|
|
109
|
+
* These are `normalizeEmbeddedPersonIds`'s two (`go/pkg/basecamp/normalize.go:83-104`)
|
|
110
|
+
* and nothing else, and the pass runs only on {@link EMBEDDED_PERSON_SERVICES}.
|
|
111
|
+
* `tests/services/person-id-normalization.test.ts` pins both halves.
|
|
112
|
+
*
|
|
113
|
+
* THIS REPLACED A WIDER SET, and the reason is worth keeping. Go has two ways a
|
|
114
|
+
* person id becomes a number: this normalizer, on the wrapper paths whose
|
|
115
|
+
* `basecamp.Person.ID` is a plain `int64`; and its decoder, because
|
|
116
|
+
* `generated.Person.Id` is `types.FlexibleInt64`, so every other `Person`-typed
|
|
117
|
+
* field converts at read time. TypeScript has no decoder, so the set was
|
|
118
|
+
* briefly widened to all twelve keys the spec types as `Person` — `assignees`,
|
|
119
|
+
* `subscribers`, `completion_subscribers` among them — to stand in for both.
|
|
120
|
+
*
|
|
121
|
+
* The derivation was right and the application was not. The keys were matched
|
|
122
|
+
* by NAME at any depth in every response, and a name that is `Person`-valued on
|
|
123
|
+
* one schema is not on every other: `creator`, `participants`, `assignees` and
|
|
124
|
+
* `person` also hold people whose id is a plain `int64` in the reference
|
|
125
|
+
* (`UpcomingSchedulePerson`, `MyAssignmentAssignee`, `OutOfOfficePerson`). A
|
|
126
|
+
* string there is a decode error in Go, and this turned it into the system
|
|
127
|
+
* actor. See {@link normalizeEmbeddedPersonIds} for the six sites.
|
|
128
|
+
*
|
|
129
|
+
* The arity is honoured because Go's type assertions carry it: the wrapper pass
|
|
130
|
+
* asserts `.(map[string]any)` for the creator and `[]any` for participants
|
|
131
|
+
* (`:85-95`), so a `creator` that is not an object is not a person.
|
|
132
|
+
*/
|
|
133
|
+
export const EMBEDDED_PERSON_KEYS = new Map([
|
|
134
|
+
["creator", "object"],
|
|
135
|
+
["participants", "array"],
|
|
136
|
+
]);
|
|
137
|
+
/**
|
|
138
|
+
* The services whose responses Go runs the positional pass over.
|
|
139
|
+
*
|
|
140
|
+
* `normalizeEmbeddedPeopleJSON` is a FUNCTION in the reference, not a layer, and
|
|
141
|
+
* it is called from exactly two places: `gauges.go:171` and
|
|
142
|
+
* `my_notifications.go:171,281,296`. Matching that is the whole point — see
|
|
143
|
+
* {@link normalizeEmbeddedPersonIds}.
|
|
144
|
+
*/
|
|
145
|
+
const EMBEDDED_PERSON_SERVICES = new Set(["Gauges", "MyNotifications"]);
|
|
146
|
+
/**
|
|
147
|
+
* Normalizes Person-shaped objects in API responses, by `personable_type`.
|
|
60
148
|
*
|
|
61
149
|
* The BC3 API conflates real Person records (numeric id) with system actors
|
|
62
|
-
* like LocalPerson (symbolic id: "basecamp", "campfire")
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
150
|
+
* like LocalPerson (symbolic id: "basecamp", "campfire"), and it serializes
|
|
151
|
+
* person ids as strings in some payloads. This is Go's `normalizePersonIds`
|
|
152
|
+
* (`go/pkg/basecamp/normalize.go:16-29`): any object at any depth carrying a
|
|
153
|
+
* `personable_type` key, whatever its value, gets {@link coercePersonId}.
|
|
154
|
+
*
|
|
155
|
+
* The key's presence is the whole test, and it is a safe one to run on every
|
|
156
|
+
* response: an object that declares itself personable IS the Person projection,
|
|
157
|
+
* whose id is `FlexibleInt64` in the reference and therefore a number to every
|
|
158
|
+
* caller. This pass predates the person-id work and its reach is unchanged by
|
|
159
|
+
* it.
|
|
160
|
+
*
|
|
161
|
+
* The POSITIONAL pass is a separate function with a much smaller reach, for
|
|
162
|
+
* reasons {@link normalizeEmbeddedPersonIds} sets out.
|
|
68
163
|
*/
|
|
69
164
|
function normalizePersonIds(obj) {
|
|
70
165
|
if (!obj || typeof obj !== "object")
|
|
@@ -75,31 +170,249 @@ function normalizePersonIds(obj) {
|
|
|
75
170
|
return;
|
|
76
171
|
}
|
|
77
172
|
const rec = obj;
|
|
78
|
-
if ("personable_type" in rec
|
|
79
|
-
|
|
80
|
-
// Strict integer check: only bare digits (with optional leading minus).
|
|
81
|
-
// Rejects "1e3", "123.0", "0x1F", "123abc" which Number() would accept.
|
|
82
|
-
if (/^-?\d+$/.test(idStr) && Number.isSafeInteger(Number(idStr))) {
|
|
83
|
-
// Numeric string in safe integer range (e.g. "12345") — coerce to number
|
|
84
|
-
rec.id = Number(idStr);
|
|
85
|
-
}
|
|
86
|
-
else if (/^-?\d+$/.test(idStr)) {
|
|
87
|
-
// Pure digits but overflows JS safe integer range — preserve as
|
|
88
|
-
// system_label since we can't represent it losslessly as a number
|
|
89
|
-
rec.system_label = idStr;
|
|
90
|
-
rec.id = 0;
|
|
91
|
-
}
|
|
92
|
-
else {
|
|
93
|
-
// Non-numeric sentinel (e.g. "basecamp" for LocalPerson)
|
|
94
|
-
rec.system_label = idStr;
|
|
95
|
-
rec.id = 0;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
173
|
+
if ("personable_type" in rec)
|
|
174
|
+
coercePersonId(rec);
|
|
98
175
|
for (const val of Object.values(rec)) {
|
|
99
176
|
if (typeof val === "object" && val !== null)
|
|
100
177
|
normalizePersonIds(val);
|
|
101
178
|
}
|
|
102
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Coerces the string ids of people embedded under `creator` and `participants`.
|
|
182
|
+
*
|
|
183
|
+
* Go's `normalizeEmbeddedPersonIds` (`go/pkg/basecamp/normalize.go:83-104`),
|
|
184
|
+
* with Go's reach: it runs ONLY where `normalizeEmbeddedPeopleJSON` is called,
|
|
185
|
+
* which is `gauges.go:171` and `my_notifications.go:171,281,296` and nowhere
|
|
186
|
+
* else. {@link EMBEDDED_PERSON_SERVICES} is that call list.
|
|
187
|
+
*
|
|
188
|
+
* It exists because the wrapper types on those two paths (Notification, Gauge,
|
|
189
|
+
* GaugeNeedle) embed a `*Person` whose `ID` is a plain `int64`, and embedded
|
|
190
|
+
* creator and participant people frequently omit `personable_type`, so the pass
|
|
191
|
+
* above skips exactly the payloads this one exists to fix.
|
|
192
|
+
*
|
|
193
|
+
* WHY IT IS NOT WIDER, WHICH IS A CORRECTION. This walk briefly ran on every
|
|
194
|
+
* response over a twelve-key set derived from the spec's `Person`-valued
|
|
195
|
+
* fields, to stand in for the decoder TypeScript does not have. The derivation
|
|
196
|
+
* was right and the APPLICATION was wrong: the keys were matched by NAME at any
|
|
197
|
+
* depth, so a name that is `Person`-valued on one schema reached every other
|
|
198
|
+
* schema that happens to use it. Three schemas have a person-shaped field under
|
|
199
|
+
* one of these names whose id is a plain `int64` in the reference, so a string
|
|
200
|
+
* there is a decode error in Go and became the SYSTEM ACTOR here:
|
|
201
|
+
*
|
|
202
|
+
* - `UpcomingSchedulePerson` — `UpcomingScheduleEntry.creator`, `.participants`,
|
|
203
|
+
* `UpcomingAssignable.assignees`, `UpcomingAssignableCompletion.creator`
|
|
204
|
+
* - `MyAssignmentAssignee` — `MyAssignment.assignees`
|
|
205
|
+
* - `OutOfOfficePerson` — `DisableOutOfOfficeOutput.person`
|
|
206
|
+
*
|
|
207
|
+
* A body the reference REFUSES outright read as person 0 with a `system_label`,
|
|
208
|
+
* on the field that says who acted — a divergence in the accepting direction on
|
|
209
|
+
* an identity field, which is the exact class this work exists to remove. It was
|
|
210
|
+
* latent (BC3 sends integers at all six sites today) and it was still wrong, and
|
|
211
|
+
* the rule it broke is the one that settles these: a port normalizes exactly
|
|
212
|
+
* where the reference does, never wider.
|
|
213
|
+
*
|
|
214
|
+
* The gap that widening was covering is real and is NOT closed here: `assignees`,
|
|
215
|
+
* `subscribers` and `completion_subscribers` carry string ids that Go's decoder
|
|
216
|
+
* converts. That is decoder coverage rather than normalizer reach, and it is
|
|
217
|
+
* closed by {@link decodeResponsePersonIds}, per operation at the generated
|
|
218
|
+
* `FlexibleInt64` sites. On the write path `writableIdList` in `merge-safe.ts`
|
|
219
|
+
* also reads the id by the same scan (PR #913).
|
|
220
|
+
*/
|
|
221
|
+
function normalizeEmbeddedPersonIds(obj) {
|
|
222
|
+
if (!obj || typeof obj !== "object")
|
|
223
|
+
return;
|
|
224
|
+
if (Array.isArray(obj)) {
|
|
225
|
+
for (const item of obj)
|
|
226
|
+
normalizeEmbeddedPersonIds(item);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const rec = obj;
|
|
230
|
+
for (const [key, val] of Object.entries(rec)) {
|
|
231
|
+
// Go's type assertions skip anything else — a `creator: "me"` or a
|
|
232
|
+
// `participants: {}` is not a person (`:85-95`), and neither is a list
|
|
233
|
+
// element that is not an object.
|
|
234
|
+
const arity = EMBEDDED_PERSON_KEYS.get(key);
|
|
235
|
+
if (arity === "object") {
|
|
236
|
+
if (isJSONObject(val))
|
|
237
|
+
coercePersonId(val);
|
|
238
|
+
}
|
|
239
|
+
else if (arity === "array" && Array.isArray(val)) {
|
|
240
|
+
for (const element of val) {
|
|
241
|
+
if (isJSONObject(element))
|
|
242
|
+
coercePersonId(element);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (typeof val === "object" && val !== null)
|
|
246
|
+
normalizeEmbeddedPersonIds(val);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Both of the reference's passes, applied the way the reference applies them:
|
|
251
|
+
* the `personable_type` pass to every response, the positional pass only on the
|
|
252
|
+
* services Go calls `normalizeEmbeddedPeopleJSON` from.
|
|
253
|
+
*/
|
|
254
|
+
function normalizeResponsePersonIds(obj, service) {
|
|
255
|
+
normalizePersonIds(obj);
|
|
256
|
+
if (EMBEDDED_PERSON_SERVICES.has(service))
|
|
257
|
+
normalizeEmbeddedPersonIds(obj);
|
|
258
|
+
}
|
|
259
|
+
/** `ParseInt`'s int64 bounds as doubles. `2^63 - 1` rounds to `2^63`; see {@link decodeFlexiblePersonId}. */
|
|
260
|
+
const INT64_MIN_DOUBLE = -(2 ** 63);
|
|
261
|
+
const INT64_MAX_DOUBLE = 2 ** 63;
|
|
262
|
+
/** Parsed {@link PERSON_ID_SITES} paths, split once per operation. */
|
|
263
|
+
const personIdSiteSegments = new Map();
|
|
264
|
+
function siteSegments(operation) {
|
|
265
|
+
let segments = personIdSiteSegments.get(operation);
|
|
266
|
+
if (segments === undefined) {
|
|
267
|
+
const sites = Object.hasOwn(PERSON_ID_SITES, operation) ? PERSON_ID_SITES[operation] : undefined;
|
|
268
|
+
if (sites === undefined)
|
|
269
|
+
return undefined;
|
|
270
|
+
segments = sites.map((site) => (site === "$" ? [] : site.split(".")));
|
|
271
|
+
personIdSiteSegments.set(operation, segments);
|
|
272
|
+
}
|
|
273
|
+
return segments;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Reads one person's `id` the way `types.FlexibleInt64.UnmarshalJSON` does
|
|
277
|
+
* (`go/pkg/types/flexible_int64.go:27-65`), writing the result in place.
|
|
278
|
+
*
|
|
279
|
+
* - a string goes through {@link scanPersonId}: a value is written as the
|
|
280
|
+
* number (`:34-37`), SYNTAX writes `0` (`:46` — no `system_label`, which only
|
|
281
|
+
* the pre-decode normalizer adds), RANGE fails the read (`:43-44`). A value
|
|
282
|
+
* outside ±(2^53 − 1) leaves the string, the {@link personIdNumber} residual.
|
|
283
|
+
* - a number must be an integer inside int64 (`:59-61`). `JSON.parse` has
|
|
284
|
+
* already rounded it, so `1024.0` and `1e3` are indistinguishable from `1024`
|
|
285
|
+
* and `1000`, and the one double both `2^63 − 1` and `2^63` round to is
|
|
286
|
+
* accepted rather than refusing a real int64 Go reads.
|
|
287
|
+
* - `null`, a boolean, an array or an object is not an int64 and fails the read
|
|
288
|
+
* (`:56-61`).
|
|
289
|
+
*/
|
|
290
|
+
function decodeFlexiblePersonId(person, operation, site, scope) {
|
|
291
|
+
const id = person.id;
|
|
292
|
+
// A plain `int64` field reads JSON null as 0 without error; left as null here,
|
|
293
|
+
// the same representation residual as an absent id. See {@link DecodeScope}.
|
|
294
|
+
if (id === null && scope.nullIdReadsZero)
|
|
295
|
+
return;
|
|
296
|
+
if (typeof id === "string") {
|
|
297
|
+
const scan = scanPersonId(id);
|
|
298
|
+
if (scan.kind === "syntax") {
|
|
299
|
+
person.id = 0;
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (scan.kind === "value") {
|
|
303
|
+
const value = personIdNumber(scan.value);
|
|
304
|
+
if (value !== undefined)
|
|
305
|
+
person.id = value;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
throw malformedPersonId(operation, site, id, "overflows int64");
|
|
309
|
+
}
|
|
310
|
+
if (typeof id === "number" && Number.isInteger(id) && id >= INT64_MIN_DOUBLE && id <= INT64_MAX_DOUBLE) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
throw malformedPersonId(operation, site, id, "is not a valid int64");
|
|
314
|
+
}
|
|
315
|
+
function malformedPersonId(operation, site, id, why) {
|
|
316
|
+
return malformedResponse(`${operation} returned a person id at ${site} that ${why}: ${JSON.stringify(id)}`, "The reference SDK refuses this response when it decodes the person id; it is not retryable.");
|
|
317
|
+
}
|
|
318
|
+
function decodeSite(node, segments, index, operation, site, scope) {
|
|
319
|
+
// The last `[]` of an array site is the list of people, handled below, so the
|
|
320
|
+
// walk stops one segment short of it.
|
|
321
|
+
const arraySite = segments.length > 0 && segments[segments.length - 1] === "[]";
|
|
322
|
+
const end = arraySite ? segments.length - 1 : segments.length;
|
|
323
|
+
if (index === end) {
|
|
324
|
+
if (arraySite) {
|
|
325
|
+
if (!Array.isArray(node))
|
|
326
|
+
return;
|
|
327
|
+
for (const person of node) {
|
|
328
|
+
if (isJSONObject(person) && Object.hasOwn(person, "id"))
|
|
329
|
+
decodeFlexiblePersonId(person, operation, site, scope);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
else if (isJSONObject(node) && Object.hasOwn(node, "id")) {
|
|
333
|
+
decodeFlexiblePersonId(node, operation, site, scope);
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const segment = segments[index];
|
|
338
|
+
if (segment === "[]") {
|
|
339
|
+
if (Array.isArray(node))
|
|
340
|
+
for (const element of node)
|
|
341
|
+
decodeSite(element, segments, index + 1, operation, site, scope);
|
|
342
|
+
}
|
|
343
|
+
else if (segment === "{}") {
|
|
344
|
+
if (isJSONObject(node))
|
|
345
|
+
for (const value of Object.values(node))
|
|
346
|
+
decodeSite(value, segments, index + 1, operation, site, scope);
|
|
347
|
+
}
|
|
348
|
+
else if (isJSONObject(node) && Object.hasOwn(node, segment)) {
|
|
349
|
+
decodeSite(node[segment], segments, index + 1, operation, site, scope);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The one typed decode Go performs on a person id, at the fields Go performs it.
|
|
354
|
+
*
|
|
355
|
+
* `generated.Person.Id` is `types.FlexibleInt64`, and every generated Go service
|
|
356
|
+
* decodes its body through `Parse<Op>Response`, so an untagged `creator` of
|
|
357
|
+
* `{"id": "7"}` on a comment is `7` in Go. This SDK has no decoder, and after
|
|
358
|
+
* the pre-decode normalizer it was still the string `"7"` in a field typed
|
|
359
|
+
* `number` (SPEC.md section 10, "Person Ids Off the Wire").
|
|
360
|
+
*
|
|
361
|
+
* The sites are {@link PERSON_ID_SITES}, generated per OPERATION from the
|
|
362
|
+
* `x-go-type` marker on `Person.id` — not by key name, which is what reached
|
|
363
|
+
* `UpcomingSchedulePerson`, `MyAssignmentAssignee` and `OutOfOfficePerson`
|
|
364
|
+
* (plain `int64` in Go) when the normalizer was widened. An operation with no
|
|
365
|
+
* entry is left exactly as the normalizer left it.
|
|
366
|
+
*
|
|
367
|
+
* Runs AFTER the normalizer on the same body, which keeps both idempotent: an id
|
|
368
|
+
* the normalizer converted is a number here, and one it left (RANGE) is refused
|
|
369
|
+
* here, as Go's decoder refuses it. A person that is `null`, not an object, or
|
|
370
|
+
* has no `id` is left alone: Go zero-fills or refuses it as part of a
|
|
371
|
+
* whole-body decode this SDK does for no field.
|
|
372
|
+
*/
|
|
373
|
+
function decodeResponsePersonIds(body, operation, scope = {}) {
|
|
374
|
+
const segments = siteSegments(operation);
|
|
375
|
+
if (segments === undefined)
|
|
376
|
+
return;
|
|
377
|
+
const sites = PERSON_ID_SITES[operation];
|
|
378
|
+
const prefix = scope.wrappedKey === undefined ? undefined : `${scope.wrappedKey}.`;
|
|
379
|
+
segments.forEach((path, i) => {
|
|
380
|
+
const site = sites[i];
|
|
381
|
+
if (prefix !== undefined && !site.startsWith(prefix))
|
|
382
|
+
return;
|
|
383
|
+
decodeSite(body, path, 0, operation, site, scope);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* The operations whose followed-page items Go decodes into HAND-WRITTEN types
|
|
388
|
+
* after the positional normalizer — `Gauge`, `GaugeNeedle`, `Notification` —
|
|
389
|
+
* whose `Person.ID` is a plain `int64` (`go/pkg/basecamp/todos.go:77-78`), not
|
|
390
|
+
* `FlexibleInt64`: `gauges.go:236-241`, `gauges.go:307-312`,
|
|
391
|
+
* `my_notifications.go:266-268,295-304`. A plain `int64` refuses what
|
|
392
|
+
* `FlexibleInt64` refuses once the normalizer has run, with one exception —
|
|
393
|
+
* JSON `null`, which `encoding/json` leaves at 0 without error. Page 1 of these
|
|
394
|
+
* still goes through `Parse<Op>Response`, where `null` is refused.
|
|
395
|
+
*/
|
|
396
|
+
const PLAIN_INT64_FOLLOWED_PAGE_OPERATIONS = new Set(["ListGauges", "ListGaugeNeedles", "GetBubbleUps"]);
|
|
397
|
+
/**
|
|
398
|
+
* Everything a generated read does to a decoded body's person ids, in Go's
|
|
399
|
+
* order: the pre-decode normalizer, then the `FlexibleInt64` decode.
|
|
400
|
+
*/
|
|
401
|
+
function processResponsePersonIds(body, info) {
|
|
402
|
+
normalizeResponsePersonIds(body, info.service);
|
|
403
|
+
decodeResponsePersonIds(body, info.operation);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* The same, for a page reached by following `Link: rel="next"`: the kept items
|
|
407
|
+
* of a bare-array page, or a wrapped page. See {@link DecodeScope}.
|
|
408
|
+
*/
|
|
409
|
+
function processFollowedPagePersonIds(body, info, wrappedKey) {
|
|
410
|
+
normalizeResponsePersonIds(body, info.service);
|
|
411
|
+
decodeResponsePersonIds(body, info.operation, {
|
|
412
|
+
wrappedKey,
|
|
413
|
+
nullIdReadsZero: PLAIN_INT64_FOLLOWED_PAGE_OPERATIONS.has(info.operation),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
103
416
|
/**
|
|
104
417
|
* Whether a rejection denotes invalid JSON rather than a failed read.
|
|
105
418
|
*
|
|
@@ -123,6 +436,13 @@ function normalizePersonIds(obj) {
|
|
|
123
436
|
function isJsonSyntaxError(err) {
|
|
124
437
|
return typeof err === "object" && err !== null && err.name === "SyntaxError";
|
|
125
438
|
}
|
|
439
|
+
/**
|
|
440
|
+
* Abstract base class for all Basecamp API services.
|
|
441
|
+
*
|
|
442
|
+
* Services extend this class to inherit common functionality
|
|
443
|
+
* for making API requests, handling errors, and integrating
|
|
444
|
+
* with the hooks system.
|
|
445
|
+
*/
|
|
126
446
|
export class BaseService {
|
|
127
447
|
/** The underlying openapi-fetch client */
|
|
128
448
|
client;
|
|
@@ -241,21 +561,24 @@ export class BaseService {
|
|
|
241
561
|
}
|
|
242
562
|
// Drain response body before retry to free resources and enable connection reuse
|
|
243
563
|
response.body?.cancel();
|
|
244
|
-
// Backoff before retry.
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
564
|
+
// Backoff before retry. A Retry-After replaces the curve at every
|
|
565
|
+
// status this branch reaches (SPEC §6 "Retry-After Honouring"), and
|
|
566
|
+
// the header goes through errors.ts's parseRetryAfter — the single
|
|
567
|
+
// SPEC §6 implementation — rather than a local parseInt: the copy this
|
|
568
|
+
// replaced had no HTTP-date branch and guarded with `>= 0`, so it
|
|
569
|
+
// honoured `Retry-After: 0` as a zero-millisecond delay and retried
|
|
570
|
+
// with no wait at all.
|
|
571
|
+
const retryAfterSeconds = parseRetryAfter(response.headers.get("Retry-After"));
|
|
252
572
|
// The locally-computed term is bounded by SPEC §7's ceiling; the
|
|
253
573
|
// server-directed Retry-After is not, per the same section.
|
|
254
574
|
const delay = retryAfterSeconds !== undefined
|
|
255
575
|
? timerSafeDelayMs(retryAfterSeconds)
|
|
256
576
|
: saturatingBackoff(retryConfig.baseDelayMs ?? 1000, "exponential", attempt);
|
|
257
577
|
try {
|
|
258
|
-
|
|
578
|
+
// SPEC §7 step 3i: the status-mapped error, carrying the parsed
|
|
579
|
+
// retryAfter that governs this sleep — the same value, not a
|
|
580
|
+
// second parse — rather than a bare Error.
|
|
581
|
+
const retryError = errorFromParsedBody(response, null, response.headers.get("X-Request-Id") ?? undefined, retryAfterSeconds);
|
|
259
582
|
// SPEC section 7: RequestInfo.attempt is the attempt that just failed
|
|
260
583
|
// (1-based), while the standalone argument is the UPCOMING attempt.
|
|
261
584
|
this.hooks?.onRetry?.({ method, url, attempt: attempt + 1 }, attempt + 2, retryError, delay);
|
|
@@ -319,7 +642,7 @@ export class BaseService {
|
|
|
319
642
|
if (response.status === 204 || data === undefined) {
|
|
320
643
|
return undefined;
|
|
321
644
|
}
|
|
322
|
-
|
|
645
|
+
processResponsePersonIds(data, info);
|
|
323
646
|
return data;
|
|
324
647
|
}
|
|
325
648
|
catch (err) {
|
|
@@ -374,7 +697,7 @@ export class BaseService {
|
|
|
374
697
|
throw basecampError;
|
|
375
698
|
}
|
|
376
699
|
const firstPageItems = data ?? [];
|
|
377
|
-
|
|
700
|
+
processResponsePersonIds(firstPageItems, info);
|
|
378
701
|
const totalCount = parseTotalCount(response);
|
|
379
702
|
const maxItems = paginationOpts?.maxItems;
|
|
380
703
|
// A pinned page is the whole answer: return it without following links.
|
|
@@ -392,7 +715,7 @@ export class BaseService {
|
|
|
392
715
|
return new ListResult(firstPageItems.slice(0, maxItems), { totalCount, truncated: hasMore });
|
|
393
716
|
}
|
|
394
717
|
// Follow pagination
|
|
395
|
-
const { items: allItems, truncated } = await this.followPagination(response, firstPageItems, maxItems);
|
|
718
|
+
const { items: allItems, truncated } = await this.followPagination(response, firstPageItems, maxItems, info);
|
|
396
719
|
// Update duration to reflect total time across all pages
|
|
397
720
|
result.durationMs = Math.round(performance.now() - start);
|
|
398
721
|
return new ListResult(allItems, { totalCount, truncated });
|
|
@@ -441,7 +764,7 @@ export class BaseService {
|
|
|
441
764
|
throw basecampError;
|
|
442
765
|
}
|
|
443
766
|
const firstPageData = (data ?? {});
|
|
444
|
-
|
|
767
|
+
processResponsePersonIds(firstPageData, info);
|
|
445
768
|
const totalCount = parseTotalCount(response);
|
|
446
769
|
// Extract wrapper fields (everything except the paginated key)
|
|
447
770
|
const wrapper = {};
|
|
@@ -466,7 +789,7 @@ export class BaseService {
|
|
|
466
789
|
return { ...wrapper, [key]: listResult };
|
|
467
790
|
}
|
|
468
791
|
// Follow pagination, extracting items from key on each subsequent page
|
|
469
|
-
const { items: allItems, truncated } = await this.followPaginationWrapped(response, firstPageItems, key, maxItems);
|
|
792
|
+
const { items: allItems, truncated } = await this.followPaginationWrapped(response, firstPageItems, key, maxItems, info);
|
|
470
793
|
result.durationMs = Math.round(performance.now() - start);
|
|
471
794
|
const listResult = new ListResult(allItems, { totalCount, truncated });
|
|
472
795
|
return { ...wrapper, [key]: listResult };
|
|
@@ -527,7 +850,7 @@ export class BaseService {
|
|
|
527
850
|
* beyond maxItems were dropped, or a next-page link was left unfetched
|
|
528
851
|
* (maxItems met at a page boundary, or the page safety cap).
|
|
529
852
|
*/
|
|
530
|
-
async followPagination(initialResponse, firstPageItems, maxItems) {
|
|
853
|
+
async followPagination(initialResponse, firstPageItems, maxItems, info) {
|
|
531
854
|
const allItems = [...firstPageItems];
|
|
532
855
|
let response = initialResponse;
|
|
533
856
|
const initialUrl = initialResponse.url;
|
|
@@ -545,14 +868,21 @@ export class BaseService {
|
|
|
545
868
|
throw await errorFromResponse(response, response.headers.get("X-Request-Id") ?? undefined);
|
|
546
869
|
}
|
|
547
870
|
const pageItems = await this.parsePage(response, page + 1);
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
|
|
553
|
-
|
|
871
|
+
// Trim to the cap BEFORE decoding. Go collects followed pages as
|
|
872
|
+
// `[]json.RawMessage`, trims them to the limit, and only the kept items
|
|
873
|
+
// are decoded (`go/pkg/basecamp/client.go:620-631`), so an item past the
|
|
874
|
+
// cap on a followed page can never fail the read. Page 1 is decoded whole
|
|
875
|
+
// by `Parse<Op>Response` before the cap, and is processed that way above.
|
|
876
|
+
const capped = !!maxItems && maxItems > 0 && allItems.length + pageItems.length >= maxItems;
|
|
877
|
+
const kept = capped ? pageItems.slice(0, maxItems - allItems.length) : pageItems;
|
|
878
|
+
processFollowedPagePersonIds(kept, info);
|
|
879
|
+
allItems.push(...kept);
|
|
880
|
+
// Only mark truncated when items were actually dropped, or the
|
|
881
|
+
// just-fetched page links to a further page.
|
|
882
|
+
if (capped) {
|
|
883
|
+
const hasMore = kept.length < pageItems.length
|
|
554
884
|
|| parseNextLink(response.headers.get("Link")) !== null;
|
|
555
|
-
return { items: allItems
|
|
885
|
+
return { items: allItems, truncated: hasMore };
|
|
556
886
|
}
|
|
557
887
|
}
|
|
558
888
|
// If we exited the loop because page >= maxPages and there's still a next link,
|
|
@@ -564,7 +894,7 @@ export class BaseService {
|
|
|
564
894
|
* Follows Link header pagination for wrapped responses.
|
|
565
895
|
* Each page is a wrapper object; items are extracted from data[key].
|
|
566
896
|
*/
|
|
567
|
-
async followPaginationWrapped(initialResponse, firstPageItems, key, maxItems) {
|
|
897
|
+
async followPaginationWrapped(initialResponse, firstPageItems, key, maxItems, info) {
|
|
568
898
|
const allItems = [...firstPageItems];
|
|
569
899
|
let response = initialResponse;
|
|
570
900
|
const initialUrl = initialResponse.url;
|
|
@@ -581,7 +911,9 @@ export class BaseService {
|
|
|
581
911
|
throw await errorFromResponse(response, response.headers.get("X-Request-Id") ?? undefined);
|
|
582
912
|
}
|
|
583
913
|
const pageData = await this.parsePage(response, page + 1);
|
|
584
|
-
|
|
914
|
+
// Every item on the page is decoded before the cap trims, as Go's
|
|
915
|
+
// `GetPersonProgress` loop does (timeline.go:452-464) — but only under the key.
|
|
916
|
+
processFollowedPagePersonIds(pageData, info, key);
|
|
585
917
|
const pageItems = pageData[key] ?? [];
|
|
586
918
|
allItems.push(...pageItems);
|
|
587
919
|
// Check maxItems cap. Only mark truncated when items were actually
|