@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.
Files changed (94) hide show
  1. package/README.md +94 -2
  2. package/dist/client.d.ts +6 -6
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +11 -7
  5. package/dist/client.js.map +1 -1
  6. package/dist/download.js +1 -1
  7. package/dist/errors.d.ts +10 -1
  8. package/dist/errors.d.ts.map +1 -1
  9. package/dist/errors.js +42 -13
  10. package/dist/errors.js.map +1 -1
  11. package/dist/generated/metadata.d.ts +1 -2
  12. package/dist/generated/metadata.d.ts.map +1 -1
  13. package/dist/generated/metadata.js +47 -1
  14. package/dist/generated/metadata.js.map +1 -1
  15. package/dist/generated/metadata.ts +48 -3
  16. package/dist/generated/openapi-stripped.json +816 -60
  17. package/dist/generated/path-mapping.d.ts.map +1 -1
  18. package/dist/generated/path-mapping.js +7 -4
  19. package/dist/generated/path-mapping.js.map +1 -1
  20. package/dist/generated/path-mapping.ts +8 -5
  21. package/dist/generated/person-id-sites.d.ts +2 -0
  22. package/dist/generated/person-id-sites.d.ts.map +1 -0
  23. package/dist/generated/person-id-sites.js +689 -0
  24. package/dist/generated/person-id-sites.js.map +1 -0
  25. package/dist/generated/person-id-sites.ts +690 -0
  26. package/dist/generated/schema.d.ts +643 -12
  27. package/dist/generated/services/event-feed.d.ts +92 -0
  28. package/dist/generated/services/event-feed.d.ts.map +1 -0
  29. package/dist/generated/services/event-feed.js +80 -0
  30. package/dist/generated/services/event-feed.js.map +1 -0
  31. package/dist/generated/services/event-feed.ts +153 -0
  32. package/dist/generated/services/gauges.d.ts +7 -3
  33. package/dist/generated/services/gauges.d.ts.map +1 -1
  34. package/dist/generated/services/gauges.js +4 -1
  35. package/dist/generated/services/gauges.js.map +1 -1
  36. package/dist/generated/services/gauges.ts +10 -3
  37. package/dist/generated/services/index.d.ts +1 -1
  38. package/dist/generated/services/index.d.ts.map +1 -1
  39. package/dist/generated/services/index.js +1 -1
  40. package/dist/generated/services/index.js.map +1 -1
  41. package/dist/generated/services/index.ts +1 -1
  42. package/dist/generated/services/lineup.d.ts +13 -0
  43. package/dist/generated/services/lineup.d.ts.map +1 -1
  44. package/dist/generated/services/lineup.js +18 -0
  45. package/dist/generated/services/lineup.js.map +1 -1
  46. package/dist/generated/services/lineup.ts +26 -0
  47. package/dist/index.d.ts +7 -4
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +12 -6
  50. package/dist/index.js.map +1 -1
  51. package/dist/person-id.d.ts +133 -0
  52. package/dist/person-id.d.ts.map +1 -0
  53. package/dist/person-id.js +163 -0
  54. package/dist/person-id.js.map +1 -0
  55. package/dist/retry.d.ts +2 -1
  56. package/dist/retry.d.ts.map +1 -1
  57. package/dist/retry.js +21 -11
  58. package/dist/retry.js.map +1 -1
  59. package/dist/services/base.d.ts +35 -0
  60. package/dist/services/base.d.ts.map +1 -1
  61. package/dist/services/base.js +387 -55
  62. package/dist/services/base.js.map +1 -1
  63. package/dist/services/comments-extensions.d.ts +89 -0
  64. package/dist/services/comments-extensions.d.ts.map +1 -0
  65. package/dist/services/comments-extensions.js +166 -0
  66. package/dist/services/comments-extensions.js.map +1 -0
  67. package/dist/services/mentions.d.ts +255 -0
  68. package/dist/services/mentions.d.ts.map +1 -0
  69. package/dist/services/mentions.js +1431 -0
  70. package/dist/services/mentions.js.map +1 -0
  71. package/dist/services/merge-safe.d.ts +13 -5
  72. package/dist/services/merge-safe.d.ts.map +1 -1
  73. package/dist/services/merge-safe.js +71 -13
  74. package/dist/services/merge-safe.js.map +1 -1
  75. package/dist/services/recordings-extensions.d.ts +354 -0
  76. package/dist/services/recordings-extensions.d.ts.map +1 -0
  77. package/dist/services/recordings-extensions.js +1940 -0
  78. package/dist/services/recordings-extensions.js.map +1 -0
  79. package/package.json +4 -4
  80. package/src/generated/metadata.ts +48 -3
  81. package/src/generated/openapi-stripped.json +816 -60
  82. package/src/generated/path-mapping.ts +8 -5
  83. package/src/generated/person-id-sites.ts +690 -0
  84. package/src/generated/schema.d.ts +643 -12
  85. package/src/generated/services/event-feed.ts +153 -0
  86. package/src/generated/services/gauges.ts +10 -3
  87. package/src/generated/services/index.ts +1 -1
  88. package/src/generated/services/lineup.ts +26 -0
  89. package/dist/generated/services/automation.d.ts +0 -25
  90. package/dist/generated/services/automation.d.ts.map +0 -1
  91. package/dist/generated/services/automation.js +0 -33
  92. package/dist/generated/services/automation.js.map +0 -1
  93. package/dist/generated/services/automation.ts +0 -49
  94. 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":"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"}
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;AAG5H,OAAO,EAAE,UAAU,EAAmB,KAAK,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAKvF,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,KAAK,YAAY,MAAM,eAAe,CAAC;AAE9C;;GAEG;AACH,MAAM,MAAM,SAAS,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,KAAK,CAAC,CAAC,CAAC;AAE/D;;GAEG;AACH,MAAM,WAAW,aAAa,CAAC,CAAC;IAC9B,IAAI,CAAC,EAAE,CAAC,CAAC;IACT,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,QAAQ,CAAC;CACpB;AAqFD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,oBAAoB,EAAE,WAAW,CAAC,MAAM,EAAE,QAAQ,GAAG,OAAO,CAG9D,CAAC;AAsUZ;;;;;;GAMG;AACH,8BAAsB,WAAW;;IAC/B,0CAA0C;IAC1C,SAAS,CAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAErC,uCAAuC;IACvC,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,aAAa,CAAC;IAEzC;;;;;;;;;;OAUG;IACH,SAAS,CAAC,QAAQ,CAAC,SAAS,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAajE,yEAAyE;IACzE,SAAS,KAAK,QAAQ,IAAI,MAAM,CAE/B;IAED,mDAAmD;IACnD,SAAS,CAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAEnC;;;OAGG;IACH,SAAS,CAAC,QAAQ,CAAC,kBAAkB,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;gBAG3F,MAAM,EAAE,SAAS,EACjB,KAAK,CAAC,EAAE,aAAa,EACrB,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,QAAQ,CAAC,EAC9C,QAAQ,CAAC,EAAE,MAAM,EACjB,kBAAkB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,EAC1E,OAAO,CAAC,EAAE,MAAM;IAyBlB;;;;;;;;;OASG;cACa,sBAAsB,CACpC,IAAI,EAAE,aAAa,EACnB,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,IAAI,GAAG,IAAI,EACjB,SAAS,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC;IAwGhB;;;;;;;OAOG;cACa,OAAO,CAAC,CAAC,EACvB,IAAI,EAAE,aAAa,EACnB,EAAE,EAAE,MAAM,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,GAClC,OAAO,CAAC,CAAC,CAAC;IAiDb;;;;;;;;;;;OAWG;cACa,gBAAgB,CAAC,CAAC,EAChC,IAAI,EAAE,aAAa,EACnB,EAAE,EAAE,MAAM,OAAO,CAAC,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,EACrC,cAAc,CAAC,EAAE,iBAAiB,GACjC,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;IA0EzB;;;;;;OAMG;cACa,uBAAuB,CAAC,CAAC,SAAS,MAAM,EAAE,KAAK,EAC7D,IAAI,EAAE,aAAa,EACnB,EAAE,EAAE,MAAM,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,EACzD,GAAG,EAAE,CAAC,EACN,cAAc,CAAC,EAAE,iBAAiB,GACjC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,EAAE,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC;IA8E3E;;;;;;;;;;;;;;;;;;;;OAoBG;YACW,SAAS;IAcvB;;;;;OAKG;YACW,gBAAgB;IAyD9B;;;OAGG;YACW,uBAAuB;IAkDrC;;;;;;OAMG;cACa,WAAW,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC;CAmBzF"}
@@ -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
- * Abstract base class for all Basecamp API services.
55
+ * Rewrites one Person-shaped object's string `id` in place.
53
56
  *
54
- * Services extend this class to inherit common functionality
55
- * for making API requests, handling errors, and integrating
56
- * with the hooks system.
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
- * Normalizes Person-shaped objects in API responses.
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"). This function
63
- * walks a response tree and, for any object with a `personable_type` field
64
- * whose `id` is a string:
65
- * - Numeric strings in safe integer range: coerced to number, no system_label
66
- * - Non-numeric sentinels (e.g. "basecamp"): id becomes 0, original preserved as system_label
67
- * - Numeric overflow (beyond Number.MAX_SAFE_INTEGER): id becomes 0, original preserved as system_label
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 && typeof rec.id === "string") {
79
- const idStr = rec.id;
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. The header goes through errors.ts's
245
- // parseRetryAfter — the single SPEC §6 implementation — rather than a
246
- // local parseInt: the copy this replaced had no HTTP-date branch and
247
- // guarded with `>= 0`, so it honoured `Retry-After: 0` as a
248
- // zero-millisecond delay and retried with no wait at all.
249
- const retryAfterSeconds = response.status === 429
250
- ? parseRetryAfter(response.headers.get("Retry-After"))
251
- : undefined;
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
- const retryError = new Error(`${response.status} ${response.statusText}`);
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
- normalizePersonIds(data);
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
- normalizePersonIds(firstPageItems);
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
- normalizePersonIds(firstPageData);
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
- normalizePersonIds(pageItems);
549
- allItems.push(...pageItems);
550
- // Check maxItems cap. Only mark truncated when items were actually
551
- // dropped, or the just-fetched page links to a further page.
552
- if (maxItems && maxItems > 0 && allItems.length >= maxItems) {
553
- const hasMore = allItems.length > maxItems
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.slice(0, maxItems), truncated: hasMore };
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
- normalizePersonIds(pageData);
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