@37signals/basecamp 0.18.0 → 0.19.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 (82) hide show
  1. package/README.md +94 -2
  2. package/dist/client.d.ts +6 -3
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +11 -5
  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 +772 -16
  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 -0
  38. package/dist/generated/services/index.d.ts.map +1 -1
  39. package/dist/generated/services/index.js +1 -0
  40. package/dist/generated/services/index.js.map +1 -1
  41. package/dist/generated/services/index.ts +1 -0
  42. package/dist/index.d.ts +6 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +11 -3
  45. package/dist/index.js.map +1 -1
  46. package/dist/person-id.d.ts +133 -0
  47. package/dist/person-id.d.ts.map +1 -0
  48. package/dist/person-id.js +163 -0
  49. package/dist/person-id.js.map +1 -0
  50. package/dist/retry.d.ts +2 -1
  51. package/dist/retry.d.ts.map +1 -1
  52. package/dist/retry.js +21 -11
  53. package/dist/retry.js.map +1 -1
  54. package/dist/services/base.d.ts +35 -0
  55. package/dist/services/base.d.ts.map +1 -1
  56. package/dist/services/base.js +387 -55
  57. package/dist/services/base.js.map +1 -1
  58. package/dist/services/comments-extensions.d.ts +89 -0
  59. package/dist/services/comments-extensions.d.ts.map +1 -0
  60. package/dist/services/comments-extensions.js +166 -0
  61. package/dist/services/comments-extensions.js.map +1 -0
  62. package/dist/services/mentions.d.ts +255 -0
  63. package/dist/services/mentions.d.ts.map +1 -0
  64. package/dist/services/mentions.js +1431 -0
  65. package/dist/services/mentions.js.map +1 -0
  66. package/dist/services/merge-safe.d.ts +13 -5
  67. package/dist/services/merge-safe.d.ts.map +1 -1
  68. package/dist/services/merge-safe.js +71 -13
  69. package/dist/services/merge-safe.js.map +1 -1
  70. package/dist/services/recordings-extensions.d.ts +354 -0
  71. package/dist/services/recordings-extensions.d.ts.map +1 -0
  72. package/dist/services/recordings-extensions.js +1940 -0
  73. package/dist/services/recordings-extensions.js.map +1 -0
  74. package/package.json +4 -4
  75. package/src/generated/metadata.ts +48 -3
  76. package/src/generated/openapi-stripped.json +772 -16
  77. package/src/generated/path-mapping.ts +8 -5
  78. package/src/generated/person-id-sites.ts +690 -0
  79. package/src/generated/schema.d.ts +643 -12
  80. package/src/generated/services/event-feed.ts +153 -0
  81. package/src/generated/services/gauges.ts +10 -3
  82. package/src/generated/services/index.ts +1 -0
@@ -0,0 +1,1940 @@
1
+ /**
2
+ * `RecordingsService.summarize` — a compact projection of one recording,
3
+ * resolved from the pointer an account event feed row or a webhook carries
4
+ * (bucket id, recording id, and the event type or recording type) through the
5
+ * typed read that type names.
6
+ *
7
+ * It exists for consumers that must decide something about a recording without
8
+ * paying for its full payload: an agent connector's admission step, an MCP tool
9
+ * answering "what is this?".
10
+ *
11
+ * The SDK has no untyped recording read (BC3 has no such route), so the type is
12
+ * the routing key: `comment.created` reads a comment, `card.created` reads a
13
+ * card, and so on — one typed read per type. Chat lines are the exception,
14
+ * because their read needs the Campfire id and the pointer does not carry it;
15
+ * `summarize` discovers the Campfire first (see {@link RecordingsService.summarize}
16
+ * and the discovery notes further down).
17
+ *
18
+ * This is hand-written composition over the generated services (SPEC §18,
19
+ * Appendix F). It makes no wire request of its own, and it mints no operation
20
+ * identity: hooks see the constituent reads under their own names (§18 rule 3).
21
+ * It lives in `src/services/*-extensions.ts` and is wired in `client.ts`, the
22
+ * placement §18 rule 5 designates for TypeScript.
23
+ */
24
+ import { BasecampError, Errors, isBasecampError, truncateErrorMessage } from "../errors.js";
25
+ import { RecordingsService as GeneratedRecordingsService } from "../generated/services/recordings.js";
26
+ import { readMentions } from "./mentions.js";
27
+ /**
28
+ * The two mention fields of a summary, from one walk of the rich text.
29
+ *
30
+ * `unnameable_mention_ids` is omitted rather than set to `[]` when there is
31
+ * nothing to report, so the key appears only on the payloads where
32
+ * `mentioned_person_ids` is actually short. `mentioned_person_ids` is always
33
+ * present, which is the existing contract.
34
+ */
35
+ function mentionsOf(richText) {
36
+ const { ids, unnameable } = readMentions(richText);
37
+ return unnameable.length > 0
38
+ ? { mentioned_person_ids: ids, unnameable_mention_ids: unnameable }
39
+ : { mentioned_person_ids: ids };
40
+ }
41
+ /**
42
+ * The base class for every failure that is the composite's own conclusion
43
+ * rather than one read's HTTP answer.
44
+ *
45
+ * It is a {@link BasecampError} so an existing `catch` keeps working, and it
46
+ * carries a `kind` so the conclusions stay distinguishable from each other and
47
+ * from the read errors whose `code` they share. Every one of them is
48
+ * **statusless**: no single request returned a status that describes the
49
+ * conclusion, so a consumer seeing `code: "not_found"` with no `httpStatus`
50
+ * knows it is looking at a composite's verdict and not at a 404 off the wire.
51
+ */
52
+ export class RecordingSummaryError extends BasecampError {
53
+ kind;
54
+ constructor(kind, code, message, options) {
55
+ super(code, message, { ...options, retryable: false });
56
+ this.name = "RecordingSummaryError";
57
+ this.kind = kind;
58
+ }
59
+ }
60
+ /**
61
+ * A {@link RecordingRef} `summarize` cannot route, raised before any request.
62
+ *
63
+ * `usage`: nothing was asked of the API — the pointer names no read.
64
+ */
65
+ export class RecordingRoutingError extends RecordingSummaryError {
66
+ ref;
67
+ constructor(ref, kind) {
68
+ // Raw, and the fallback is on exactly "", as Go's RecordingRoutingError
69
+ // reads them: routing trims before matching, but the message reports what
70
+ // the caller actually passed.
71
+ const key = ref.recordingType !== undefined && ref.recordingType !== ""
72
+ ? ref.recordingType
73
+ : (ref.eventType ?? "");
74
+ const reason = kind === "no_recording_type"
75
+ ? "event type names no recording type"
76
+ : "no typed read for recording type";
77
+ super(kind, "usage", `${reason}: ${JSON.stringify(key)}`, {
78
+ hint: kind === "no_recording_type"
79
+ ? "resolve the recording from your own record of what the event referred to"
80
+ : "pass a recording type summarizableRecordingTypes() lists, or an event type summarizableEventTypes() lists",
81
+ });
82
+ this.name = "RecordingRoutingError";
83
+ this.ref = ref;
84
+ }
85
+ }
86
+ /** A chat line found under no visible Campfire. */
87
+ export class UnresolvedRecordingError extends RecordingSummaryError {
88
+ bucketId;
89
+ recordingId;
90
+ /** The candidates tried, in order; empty when the bucket has no visible Campfire at all. */
91
+ campfireIds;
92
+ /**
93
+ * Whether the cached discovery sources were re-read before concluding. False
94
+ * when every source had been read within the last
95
+ * {@link CAMPFIRE_INDEX_MIN_REFRESH_MS}, so a Campfire created in that window
96
+ * was not seen: the conclusion stands on data up to that old, and a retry
97
+ * after the floor sees the current sources.
98
+ */
99
+ refreshed;
100
+ /**
101
+ * Candidates from the cache that the refreshed sources no longer list —
102
+ * Campfires the caller could see when the cache filled and cannot now. Set
103
+ * only when {@link refreshed}.
104
+ */
105
+ staleCampfireIds;
106
+ constructor(init) {
107
+ super("recording_unresolved", "not_found", `chat line found under no visible campfire: line ${init.recordingId} in bucket ${init.bucketId} ` +
108
+ `(tried ${init.campfireIds.length} campfires)`, {
109
+ hint: "the line is under no Campfire this credential can currently see; retry once visibility may have changed",
110
+ });
111
+ this.name = "UnresolvedRecordingError";
112
+ this.bucketId = init.bucketId;
113
+ this.recordingId = init.recordingId;
114
+ this.campfireIds = init.campfireIds;
115
+ this.refreshed = init.refreshed;
116
+ this.staleCampfireIds = init.staleCampfireIds;
117
+ }
118
+ }
119
+ /**
120
+ * Discovery stopped short of a conclusion, and why.
121
+ *
122
+ * `usage`, non-retryable, settled across every port on
123
+ * {@link https://app.basecamp.com/2914079/buckets/48699913/card_tables/cards/10308122086 | card 40}
124
+ * after the merged ports shipped two different answers — this one and Python
125
+ * said `api_error`, Kotlin said `usage`, and a caller got exit 7 from one SDK
126
+ * and exit 1 from another for the same condition.
127
+ *
128
+ * `usage` is one of only THREE coarse codes no HTTP response can produce: the
129
+ * status mapping yields `auth_required`, `forbidden`, `not_found`,
130
+ * `rate_limit`, `validation`, `limit_exceeded` and `api_error`, and `network`
131
+ * and `ambiguous` are equally unreachable from a status. `usage` is the one of
132
+ * those three that also describes a call the SDK declined to complete, which is
133
+ * why it and not the other two. A verdict the composite reached on its own
134
+ * therefore can never be read back as a constituent read's own answer — the
135
+ * property the composite exists to protect. It is
136
+ * explicitly not `not_found`, because nothing left unsearched may be reported
137
+ * absent.
138
+ *
139
+ * Retryability is a separate field and is unchanged: {@link
140
+ * RecordingSummaryError} forces `retryable: false` on every identity, and both
141
+ * of this one's reasons are deterministic for the same account state, so a
142
+ * retry loop would re-run the identical search forever.
143
+ */
144
+ export class CampfireDiscoveryIncompleteError extends RecordingSummaryError {
145
+ bucketId;
146
+ recordingId;
147
+ reason;
148
+ constructor(init) {
149
+ super("campfire_discovery_incomplete", "usage", `campfire discovery incomplete: line ${init.recordingId} in bucket ${init.bucketId}: ${init.reason}`, { hint: "candidates were left unsearched, so the line cannot be reported absent" });
150
+ this.name = "CampfireDiscoveryIncompleteError";
151
+ this.bucketId = init.bucketId;
152
+ this.recordingId = init.recordingId;
153
+ this.reason = init.reason;
154
+ }
155
+ }
156
+ /**
157
+ * A read whose bucket disagrees with the pointer.
158
+ *
159
+ * `usage`: the pointer named a bucket the recording is not in, which is the
160
+ * caller's argument being wrong rather than the API misbehaving. This doc
161
+ * comment argued the opposite — `api_error`, citing SPEC §6's statusless rule —
162
+ * for as long as the code said so, and went on saying it after the code
163
+ * changed, which is the half of a rename that editor hover and generated API
164
+ * docs actually show a consumer.
165
+ *
166
+ * The ports on `main` did NOT agree here, and the disagreement was bigger than
167
+ * the value: `exitCode` derives from the code, so this identity exited 1 in
168
+ * Python, Ruby, Kotlin and this port (`usage`) and 2 in Rust (`not_found`, "the
169
+ * recording the pointer names is not where it was looked for"). Settled for
170
+ * every SDK on {@link https://app.basecamp.com/2914079/buckets/48699913/card_tables/cards/10308966794 | card 41} as `usage`, and on the
171
+ * argument rather than the four-to-one majority: `not_found` says the recording
172
+ * is not there, which is false — the read FOUND it, in another bucket, and
173
+ * returned it. Nothing is absent. What failed is the caller's pointer.
174
+ *
175
+ * The shared fixture had no case for this identity at all, so nothing pinned
176
+ * the code: five ports carried two answers and two carried none, with every
177
+ * case green in all seven. It has one now, and it pins the code alongside the
178
+ * identity.
179
+ */
180
+ export class BucketMismatchError extends RecordingSummaryError {
181
+ ref;
182
+ /** The bucket the read returned. */
183
+ bucketId;
184
+ constructor(ref, bucketId) {
185
+ super("bucket_mismatch",
186
+ // Settled for every SDK on card 41; see the class doc above for the
187
+ // argument. The fixture had no case for this identity, so nothing pinned
188
+ // the code and five ports carried two answers with every case green. It
189
+ // has one now, so a port that reaches a different answer fails a case.
190
+ "usage", `recording is not in the requested bucket: recording ${ref.recordingId} is in bucket ${bucketId}, not ${ref.bucketId}`);
191
+ this.name = "BucketMismatchError";
192
+ this.ref = ref;
193
+ this.bucketId = bucketId;
194
+ }
195
+ }
196
+ /**
197
+ * Maps the subject of an account event feed type — everything before its final
198
+ * `.` — to a read. This is the feed's catalog (bc3 `Event::EventType`) minus
199
+ * `boost`, which names no recording type and is refused explicitly rather than
200
+ * left to fall through as unknown.
201
+ */
202
+ const EVENT_SUBJECTS = new Map([
203
+ ["comment", "comment"],
204
+ ["message", "message"],
205
+ ["todo", "todo"],
206
+ ["card", "card"],
207
+ ["chat.line", "chatLine"],
208
+ ]);
209
+ /**
210
+ * Maps BC3's recording type strings to a read. It is the routing contract, and
211
+ * it is a DELIBERATE set, not an exhaustive one: the recording types the
212
+ * account event feed's trigger matrix names (comment, message, to-do, card,
213
+ * chat line), plus the content and tool recordings a consumer reasoning about
214
+ * those is likely to hold an id for. Chat lines are matched by prefix
215
+ * (`Chat::Lines::Text`, `::RichText`, `::Code`, `::Upload`, `::Integration` all
216
+ * read through the same route); everything else exactly.
217
+ *
218
+ * A type outside this set is `unknown_recording_type` by design, whether or not
219
+ * the SDK has an id-only read for it — `Timesheet::Entry` and `Gauge::Needle`
220
+ * do, and are not routed; `Client::Reply` and `Forward::Reply` cannot be, since
221
+ * their reads need a parent id the pointer does not carry. Widening the set is
222
+ * a product decision, not a gap: add the type here, its projection in
223
+ * `readSummary`, and a case in `conformance/tests/recording_summary.json`, the
224
+ * fixture every port implements.
225
+ *
226
+ * A `Map`, not an object literal: the key is an untrusted string off an event
227
+ * feed row or a webhook, and `{}["constructor"]` is a hit. A routing table that
228
+ * answers for every `Object.prototype` member would route those to something
229
+ * that is not a read at all.
230
+ */
231
+ const RECORDING_TYPES = new Map([
232
+ ["Comment", "comment"],
233
+ ["Message", "message"],
234
+ ["Todo", "todo"],
235
+ ["Kanban::Card", "card"],
236
+ ["Document", "document"],
237
+ ["Upload", "upload"],
238
+ ["Schedule::Entry", "scheduleEntry"],
239
+ ["Question", "question"],
240
+ ["Question::Answer", "questionAnswer"],
241
+ ["Todolist", "todolist"],
242
+ ["Vault", "vault"],
243
+ ["Inbox::Forward", "forward"],
244
+ ["Client::Approval", "clientApproval"],
245
+ ["Client::Correspondence", "clientCorrespondence"],
246
+ ["GoogleDocument", "googleDocument"],
247
+ ["CloudFile", "cloudFile"],
248
+ ["Kanban::Step", "cardStep"],
249
+ ["Questionnaire", "questionnaire"],
250
+ ["Schedule", "schedule"],
251
+ ["Todoset", "todoset"],
252
+ ["Message::Board", "messageBoard"],
253
+ ["Kanban::Board", "cardTable"],
254
+ ["Kanban::Column", "cardColumn"],
255
+ ["Inbox", "inbox"],
256
+ ["Chat::Transcript", "campfire"],
257
+ ]);
258
+ const CHAT_LINE_TYPE_PREFIX = "Chat::Lines::";
259
+ /**
260
+ * The recording types `summarize` routes by `recordingType`, sorted, with the
261
+ * `Chat::Lines` subtypes represented by their shared prefix
262
+ * (`"Chat::Lines::*"`). The set is deliberate rather than exhaustive — see
263
+ * {@link RECORDING_TYPES} — and any other type is `unknown_recording_type` by
264
+ * design.
265
+ */
266
+ export function summarizableRecordingTypes() {
267
+ return [...RECORDING_TYPES.keys(), `${CHAT_LINE_TYPE_PREFIX}*`].sort();
268
+ }
269
+ /**
270
+ * The account event feed subjects `summarize` routes by `eventType` — an event
271
+ * type is `<subject>.<action>`, and any action on a listed subject routes to
272
+ * that subject's read — sorted. `boost` is absent on purpose: it is
273
+ * `no_recording_type`.
274
+ */
275
+ export function summarizableEventTypes() {
276
+ return [...EVENT_SUBJECTS.keys()].map((subject) => `${subject}.*`).sort();
277
+ }
278
+ /**
279
+ * Whether a chat line subtype carries rich text — the two that declare
280
+ * `rich_text_attribute :content` in BC3, and so the only two whose content can
281
+ * hold a mention. A Text line's content is HTML-escaped on the way out
282
+ * (`content_helper.rb`, `format_chat_line_with`), a Code line's is served
283
+ * verbatim — a snippet that happens to contain a `bc-attachment` tag — and an
284
+ * Upload line has no content.
285
+ */
286
+ function chatLineIsRichText(lineType) {
287
+ return lineType === "Chat::Lines::RichText" || lineType === "Chat::Lines::Integration";
288
+ }
289
+ /** Picks the read for a ref. `recordingType` wins when set. */
290
+ function routeRecording(ref) {
291
+ const recordingType = ref.recordingType?.trim() ?? "";
292
+ if (recordingType !== "") {
293
+ if (recordingType.startsWith(CHAT_LINE_TYPE_PREFIX))
294
+ return "chatLine";
295
+ const kind = RECORDING_TYPES.get(recordingType);
296
+ if (kind !== undefined)
297
+ return kind;
298
+ throw new RecordingRoutingError(ref, "unknown_recording_type");
299
+ }
300
+ const eventType = ref.eventType?.trim() ?? "";
301
+ if (eventType === "")
302
+ throw new RecordingRoutingError(ref, "unknown_recording_type");
303
+ // A feed type is "<subject>.<action>"; the subject names the recording type.
304
+ // A string with no action is not a feed type and is not routed.
305
+ const dot = eventType.lastIndexOf(".");
306
+ if (dot <= 0 || dot === eventType.length - 1) {
307
+ throw new RecordingRoutingError(ref, "unknown_recording_type");
308
+ }
309
+ const subject = eventType.slice(0, dot);
310
+ if (subject === "boost")
311
+ throw new RecordingRoutingError(ref, "no_recording_type");
312
+ const kind = EVENT_SUBJECTS.get(subject);
313
+ if (kind !== undefined)
314
+ return kind;
315
+ throw new RecordingRoutingError(ref, "unknown_recording_type");
316
+ }
317
+ // =============================================================================
318
+ // Campfire discovery
319
+ // =============================================================================
320
+ /**
321
+ * How long a cached discovery source — a bucket's project dock, the account's
322
+ * Campfire listing — is reused before it is read again.
323
+ */
324
+ export const CAMPFIRE_INDEX_TTL_MS = 10 * 60 * 1000;
325
+ /**
326
+ * Bounds the refresh-on-miss: a line found under no candidate re-reads the
327
+ * cached sources, but not more often than this per source, so a run of
328
+ * unresolvable lines cannot turn into a listing per line.
329
+ * {@link UnresolvedRecordingError.refreshed} says whether the floor applied.
330
+ */
331
+ const CAMPFIRE_INDEX_MIN_REFRESH_MS = 30 * 1000;
332
+ /**
333
+ * Bounds how many Campfires one `summarize` call tries, across both sources and
334
+ * the refresh. A project has one Campfire and a handful of pings; a bucket past
335
+ * this bound is not a shape BC3 produces, and the call reports
336
+ * `campfire_discovery_incomplete` rather than calling the rest absent.
337
+ */
338
+ export const MAX_CAMPFIRE_CANDIDATES = 50;
339
+ /**
340
+ * Caps the account-wide Campfire listing the fallback source reads. A listing
341
+ * that overflows it is not cached and the call reports
342
+ * `campfire_discovery_incomplete`: the dock covers every project, so the
343
+ * listing only ever serves the leftover, and an account with more Campfires
344
+ * than this should not pay a full walk per ten minutes for it.
345
+ */
346
+ export const MAX_CAMPFIRE_LISTING = 1000;
347
+ /**
348
+ * Bounds each discovery cache's entry count. The dock cache holds one snapshot
349
+ * per bucket consulted within the TTL: a connector listening across every
350
+ * project an agent can see touches hundreds of buckets, not thousands, and a
351
+ * snapshot is a handful of ids, so 1024 is generous headroom, and the bound
352
+ * exists so that a process alive for weeks can never grow past it whatever it
353
+ * sees. When the bound is reached the oldest-fetched entries go first,
354
+ * deterministically.
355
+ */
356
+ const CAMPFIRE_INDEX_MAX_ITEMS = 1024;
357
+ /**
358
+ * A per-key cache with single-flight loading: concurrent callers for one key
359
+ * await the one load in progress rather than loading again, and a failed load
360
+ * leaves the previous value in place and is not cached.
361
+ *
362
+ * The clock is `performance.now()`, which is monotonic: a wall-clock adjustment
363
+ * cannot make a snapshot look newer than it is, or make the refresh floor
364
+ * elapse early.
365
+ *
366
+ * Go's version additionally distinguishes a load that failed because the
367
+ * *loading caller's* context was cancelled, so a live waiter can load for
368
+ * itself. There is no per-call cancellation to attribute here — the services
369
+ * take no signal — so a failed load's rejection is simply shared by everyone
370
+ * waiting on it, which is the half of that rule that matters: N waiters never
371
+ * re-run one failed load N times.
372
+ */
373
+ class TtlCache {
374
+ #entries = new Map();
375
+ #inflight = new Map();
376
+ #ttlMs;
377
+ #floorMs;
378
+ #maxItems;
379
+ #now;
380
+ #seq = 0;
381
+ constructor(ttlMs, floorMs, maxItems, now = () => performance.now()) {
382
+ this.#ttlMs = ttlMs;
383
+ this.#floorMs = floorMs;
384
+ this.#maxItems = maxItems;
385
+ this.#now = now;
386
+ }
387
+ /**
388
+ * The cached value for `key` when one is within the TTL, without loading. It
389
+ * lets a caller consult what a source already holds before deciding whether
390
+ * to pay for a fetch of it.
391
+ */
392
+ peek(key) {
393
+ const entry = this.#entries.get(key);
394
+ if (entry === undefined || this.#now() - entry.fetchedAt >= this.#ttlMs)
395
+ return undefined;
396
+ return { value: entry.value, fetchedAt: entry.fetchedAt, cached: true };
397
+ }
398
+ /**
399
+ * The value for `key`, loading it when absent or older than the TTL — or,
400
+ * with `refresh` set, older than the floor.
401
+ */
402
+ async get(key, refresh, load) {
403
+ const entry = this.#entries.get(key);
404
+ if (entry !== undefined) {
405
+ const age = this.#now() - entry.fetchedAt;
406
+ if (age < this.#ttlMs && (!refresh || age < this.#floorMs)) {
407
+ return { value: entry.value, fetchedAt: entry.fetchedAt, cached: true };
408
+ }
409
+ }
410
+ const pending = this.#inflight.get(key);
411
+ // The load this call waits on is this call's load: its value is handed over
412
+ // as fresh, not as something that predated the call.
413
+ //
414
+ // A waiter shares the load's outcome unconditionally, success or failure,
415
+ // and nothing here reads the rejection to decide otherwise. That is what
416
+ // keeps one failed load from becoming one request per waiter, and it is
417
+ // also why the trap Go's `callerDone` exists to avoid cannot be sprung
418
+ // here: Go has to tell a load that failed on the LOADING caller's dead
419
+ // context from one that failed on its own, so a still-live waiter can load
420
+ // again, and it deliberately refuses to decide that from the error alone —
421
+ // an `http.Client.Timeout` satisfies `errors.Is(err,
422
+ // context.DeadlineExceeded)` with the owner still live. TypeScript has the
423
+ // same ambiguity in a sharper form, since an aborted `fetch` rejects with
424
+ // an `AbortError` whether a timeout or a caller fired the signal. There is
425
+ // nothing to attribute: the services take no per-call signal, so no caller
426
+ // can be dead while another is live, and no waiter ever re-runs a load.
427
+ //
428
+ // That is a property of the CALL SITES, not of the client — client.ts does
429
+ // thread an AbortSignal through its middleware and retry loop. Giving
430
+ // summarize() a per-call signal would make a load abortable by one caller
431
+ // while another still waits, and this paragraph would stop being true: Go's
432
+ // attribution would have to be ported with it, and it cannot be done from
433
+ // the rejection, since an aborted fetch rejects with an AbortError
434
+ // whichever signal fired.
435
+ if (pending !== undefined)
436
+ return pending;
437
+ // The order of these three statements is the release guarantee, and it is
438
+ // load-bearing. An `async` function never throws synchronously — a throw in
439
+ // its body becomes a rejection — so invoking the wrapper always yields a
440
+ // promise; `.finally` is attached to that promise; and only then is the key
441
+ // marked in flight. There is therefore no instant at which the slot is held
442
+ // without a release handler attached to the thing holding it, and every way
443
+ // the wrapper can end — `load()` throwing synchronously, its promise
444
+ // rejecting, `#publish` throwing — settles that promise and runs the
445
+ // `finally`. Go reaches the same guarantee the other way round, with a
446
+ // deferred publish that survives a panicking loader.
447
+ //
448
+ // The one exit this cannot cover is a `load()` that never settles, which
449
+ // would hold the slot and park every later caller for the life of the
450
+ // process. Go's waiters escape that through their own context; these have
451
+ // no signal to escape with. What closes it is one layer down: every request
452
+ // a loader makes carries the client's request timeout, so a load that is
453
+ // going nowhere rejects rather than hanging.
454
+ //
455
+ // A loader must not re-enter this cache for its own key. It would not join
456
+ // the load in progress — the slot is taken after `load()` is invoked, where
457
+ // Go takes it before — and would start a second one. Neither loader here
458
+ // does; both call a single generated read.
459
+ const started = (async () => {
460
+ const value = await load();
461
+ return this.#publish(key, value);
462
+ })().finally(() => {
463
+ // A failed load leaves the previous entry in place and caches nothing; the
464
+ // key is released either way so the next caller can load again.
465
+ //
466
+ // By IDENTITY, not by key. A blind `delete(key)` would drop whatever is
467
+ // registered there, and if that were ever a later caller's live load, the
468
+ // single-flight guarantee would go with it — two concurrent account-wide
469
+ // listings under one key, which is process-wide, since that key is a
470
+ // constant. The ordering above makes that unreachable today: a later
471
+ // caller can only register after this handler has vacated the slot,
472
+ // because until then it sees the pending promise and waits on it. The
473
+ // guard makes the property local anyway, so a second vacate path added
474
+ // later cannot quietly reintroduce it.
475
+ if (this.#inflight.get(key) === started)
476
+ this.#inflight.delete(key);
477
+ });
478
+ this.#inflight.set(key, started);
479
+ return started;
480
+ }
481
+ #publish(key, value) {
482
+ const fetchedAt = this.#now();
483
+ this.#sweep();
484
+ this.#makeRoom(key);
485
+ this.#entries.set(key, { value, fetchedAt, seq: ++this.#seq });
486
+ return { value, fetchedAt, cached: false };
487
+ }
488
+ /**
489
+ * Drops every entry past its TTL. It runs at each publication — the one
490
+ * moment the cache does work proportional to a miss anyway — so a long-lived
491
+ * client that has seen many buckets keeps a snapshot for at most a TTL past
492
+ * its last use plus the interval to the next load on any key, rather than for
493
+ * its lifetime.
494
+ */
495
+ #sweep() {
496
+ const now = this.#now();
497
+ for (const [key, entry] of this.#entries) {
498
+ if (now - entry.fetchedAt >= this.#ttlMs)
499
+ this.#entries.delete(key);
500
+ }
501
+ }
502
+ /**
503
+ * Evicts the oldest-fetched entries until the one about to be stored for
504
+ * `key` fits under the bound — oldest by fetch time, and by publication order
505
+ * among equals, so the choice is total rather than whatever iteration order
506
+ * happens to visit first. Oldest-first is also the right order: the entry
507
+ * nearest its TTL is the one least worth keeping.
508
+ */
509
+ #makeRoom(key) {
510
+ if (this.#maxItems <= 0)
511
+ return;
512
+ if (this.#entries.has(key))
513
+ return; // an overwrite takes no new room
514
+ while (this.#entries.size >= this.#maxItems) {
515
+ let oldestKey;
516
+ let oldest = Number.POSITIVE_INFINITY;
517
+ let oldestSeq = Number.POSITIVE_INFINITY;
518
+ for (const [candidate, entry] of this.#entries) {
519
+ if (entry.fetchedAt < oldest || (entry.fetchedAt === oldest && entry.seq < oldestSeq)) {
520
+ oldestKey = candidate;
521
+ oldest = entry.fetchedAt;
522
+ oldestSeq = entry.seq;
523
+ }
524
+ }
525
+ if (oldestKey === undefined)
526
+ return;
527
+ this.#entries.delete(oldestKey);
528
+ }
529
+ }
530
+ }
531
+ /** The single key the account-wide listing cache is stored under. */
532
+ const LISTING_KEY = "campfires";
533
+ /**
534
+ * The two discovery sources.
535
+ *
536
+ * A client is bound to one credential and one account, so entries are never
537
+ * shared across authorization contexts and a bucket id is a sufficient key.
538
+ * Expired snapshots are swept at each load and each cache is bounded
539
+ * ({@link CAMPFIRE_INDEX_MAX_ITEMS}, oldest out first), so the index holds at
540
+ * most the buckets consulted within the last TTL, and never more than the
541
+ * bound, whatever the client has seen.
542
+ */
543
+ class CampfireIndex {
544
+ #docks;
545
+ #listings;
546
+ constructor(now) {
547
+ this.#docks = new TtlCache(CAMPFIRE_INDEX_TTL_MS, CAMPFIRE_INDEX_MIN_REFRESH_MS, CAMPFIRE_INDEX_MAX_ITEMS, now);
548
+ this.#listings = new TtlCache(CAMPFIRE_INDEX_TTL_MS, CAMPFIRE_INDEX_MIN_REFRESH_MS, CAMPFIRE_INDEX_MAX_ITEMS, now);
549
+ }
550
+ /**
551
+ * The Campfire ids a bucket's project dock names. A bucket that is not a
552
+ * project (a 404 on the project read) has none; any other failure of the read
553
+ * is raised.
554
+ */
555
+ async dockCampfires(sources, bucketId, refresh) {
556
+ const hit = await this.#docks.get(bucketId, refresh, async () => {
557
+ let project;
558
+ try {
559
+ project = await sources.projects.get(bucketId);
560
+ }
561
+ catch (err) {
562
+ if (isNotFound(err))
563
+ return [];
564
+ throw err;
565
+ }
566
+ // Two layers, as Go has two: its decoder refuses a dock id that is not a
567
+ // number and fails the whole project read, and only then does it skip a
568
+ // zero id. TypeScript decodes nothing, so the first layer is the explicit
569
+ // refusal below — without it a malformed id would be dropped silently and
570
+ // discovery could go on to report the line unresolved, a composite
571
+ // verdict standing in for a failed read.
572
+ return sourceItems(sourceObject(project, "the project read").dock, "the project dock")
573
+ .filter((item) => recordingText(item.name, "a project dock item name", DISCOVERY_HINT) === "chat")
574
+ .map((item) => numericId(item.id, "project dock item"))
575
+ .filter((id) => id !== 0);
576
+ });
577
+ return { ids: hit.value, fetchedAt: hit.fetchedAt, cached: hit.cached };
578
+ }
579
+ /**
580
+ * The Campfire ids the cached account-wide listing shows in a bucket, without
581
+ * fetching: `undefined` when the listing is not cached or has expired.
582
+ */
583
+ cachedListedCampfires(bucketId) {
584
+ const hit = this.#listings.peek(LISTING_KEY);
585
+ if (hit === undefined)
586
+ return undefined;
587
+ return { ids: [...(hit.value.get(bucketId) ?? [])], fetchedAt: hit.fetchedAt, cached: true };
588
+ }
589
+ /**
590
+ * The Campfire ids the account-wide listing shows in a bucket. A listing that
591
+ * overflows {@link MAX_CAMPFIRE_LISTING} is not cached and is reported as
592
+ * incomplete discovery by the caller.
593
+ */
594
+ async listedCampfires(sources, bucketId, refresh) {
595
+ const hit = await this.#listings.get(LISTING_KEY, refresh, async () => {
596
+ const list = await sources.campfires.list({ maxItems: MAX_CAMPFIRE_LISTING });
597
+ if (list.meta.truncated)
598
+ throw new CampfireListingOverflow();
599
+ const byBucket = new Map();
600
+ for (const campfire of sourceItems(list, "the campfire listing")) {
601
+ // The bucket OBJECT, not only its id: a string or an array here left
602
+ // `?.id` undefined and the entry was dropped in silence, where Go's
603
+ // decode of the same body fails the listing read. A JSON null is the
604
+ // measured exception — the zero struct, whose zero bucket id the filter
605
+ // below skips, exactly as Go's `c.Bucket == nil` does.
606
+ const bucket = sourceObject(campfire.bucket, "campfire listing bucket");
607
+ // Go filters this source on the bucket alone — a zero campfire id is a
608
+ // candidate there, spends budget, and lands in the unresolved verdict's
609
+ // campfireIds — so it is kept here too. Only a value Go's decoder would
610
+ // have refused outright is refused.
611
+ const campfireId = numericId(campfire.id, "campfire listing entry");
612
+ // `wireInteger`, not `numericId`: Go's line here is `c.Bucket == nil ||
613
+ // c.Bucket.ID == 0` and then `byBucket[c.Bucket.ID]` — a map key and a
614
+ // comparand. Only `c.ID` below is ever put in a URL, so only it carries
615
+ // the safe-integer clause. Routing this one through `numericId` failed
616
+ // the whole listing over one entry in ANOTHER bucket, which is worse
617
+ // than what happens now: such an entry is skipped, and the healthy ones
618
+ // beside it are kept.
619
+ const listedBucketId = wireInteger(bucket.id, "campfire listing bucket");
620
+ if (listedBucketId === 0)
621
+ continue;
622
+ // Unreachable while `summarize` refuses a caller `bucketId` past 2^53:
623
+ // every rounded value above that is itself above MAX_SAFE_INTEGER, so
624
+ // it can never equal a safe id and the filter below already drops it.
625
+ // Stated here anyway, because the property it rests on lives in another
626
+ // method — and reverting this line is behaviour-neutral, which the
627
+ // guard-checking harness records as such rather than as a gap. What
628
+ // makes it worth stating: rounding is not injective, so two int64
629
+ // buckets can land on one double, and a key built from one would offer
630
+ // another project's Campfire as a candidate.
631
+ if (!Number.isSafeInteger(listedBucketId))
632
+ continue;
633
+ const ids = byBucket.get(listedBucketId);
634
+ if (ids === undefined)
635
+ byBucket.set(listedBucketId, [campfireId]);
636
+ else
637
+ ids.push(campfireId);
638
+ }
639
+ return byBucket;
640
+ });
641
+ return { ids: [...(hit.value.get(bucketId) ?? [])], fetchedAt: hit.fetchedAt, cached: hit.cached };
642
+ }
643
+ }
644
+ /**
645
+ * The load failure for a listing past its cap; `#resolveChatLine` turns it into
646
+ * the typed {@link CampfireDiscoveryIncompleteError}. Module-private and never
647
+ * a `BasecampError`, so it can never be mistaken for a read error a caller
648
+ * could see or reach one uncaught.
649
+ */
650
+ class CampfireListingOverflow extends Error {
651
+ constructor() {
652
+ // Go's sentinel carries the constant's NAME, not its value, and `reason`
653
+ // is a public field a consumer or a shared fixture can match on.
654
+ super("campfire listing exceeds MaxCampfireListing");
655
+ this.name = "CampfireListingOverflow";
656
+ }
657
+ }
658
+ /**
659
+ * The body of a routed read, refused when it is not a recording object.
660
+ *
661
+ * Go decodes each read into a struct, so a body that is not a JSON object never
662
+ * reaches the projection: it fails the read. TypeScript decodes nothing, and
663
+ * the cost is not a cosmetic one — the arms dereference the body immediately
664
+ * (`c.title`), so a bare `"hello"` projected every scalar to its default and,
665
+ * worse, left `bucket` absent, which is how a recording in ANOTHER project came
666
+ * back as a match instead of a `bucket_mismatch`. A malformed body decided a
667
+ * verdict, which is the line at which this port replicates Go's decoder.
668
+ *
669
+ * `null` is the exception, and it is measured rather than assumed: `json.Unmarshal`
670
+ * takes a JSON null into a struct as a no-op, so Go returns the ZERO recording
671
+ * and no error. That is projected here as the zero recording too — and it is
672
+ * also the shape that used to throw a raw TypeError out of the SDK, since the
673
+ * arm dereferenced it before anything could check. An array is a decode error
674
+ * there, so it is refused here.
675
+ */
676
+ function recordingBody(value) {
677
+ if (value === null)
678
+ return {};
679
+ if (typeof value !== "object" || Array.isArray(value)) {
680
+ throw Errors.apiError(truncateErrorMessage(`the recording read returned ${describeWireValue(value)} rather than a recording`), undefined, {
681
+ retryable: false,
682
+ hint: "the response is malformed; the recording it names cannot be summarized from it",
683
+ });
684
+ }
685
+ return value;
686
+ }
687
+ /**
688
+ * A nested object a discovery source carries, refused when it is not one.
689
+ *
690
+ * The id sweep covered the id FIELD at each site and not the container around
691
+ * it, and the container has the same two failure modes with worse consequences.
692
+ * Measured through the generated structs: a dock or a listing entry that is not
693
+ * an object fails Go's decode outright, and a JSON `null` is the zero struct —
694
+ * whose empty name and zero ids each site already skips. TypeScript did neither:
695
+ * a non-object was read for fields it does not have and dropped in silence, and
696
+ * a `null` threw a raw TypeError out of the SDK on a body Go reads happily.
697
+ */
698
+ function sourceObject(value, what, hint = DISCOVERY_HINT) {
699
+ if (value === null || value === undefined)
700
+ return {};
701
+ if (typeof value !== "object" || Array.isArray(value)) {
702
+ throw Errors.apiError(truncateErrorMessage(`${what} is ${describeWireValue(value)} rather than an object`), undefined, { retryable: false, hint });
703
+ }
704
+ return value;
705
+ }
706
+ /**
707
+ * The hints these helpers carry. Two, because the same shape checks run on two
708
+ * different reads: a Campfire discovery source, and the recording itself.
709
+ *
710
+ * The projection borrowed the discovery wording for a while, so a malformed
711
+ * `assignees` on a to-do read advised the caller about Campfires. Fixing that in
712
+ * the container helpers left it unfixed one helper along, in `wireInteger` and
713
+ * `numericId`, whose recording-side callers went on saying it — which is why
714
+ * this is a parameter on every one of them rather than a constant inside the
715
+ * two that were noticed first.
716
+ */
717
+ const DISCOVERY_HINT = "the discovery source's response is malformed; no Campfire can be read from it";
718
+ const RECORDING_HINT = "the response is malformed; the recording cannot be summarized from it";
719
+ /** {@link sourceItems}, for a list read off the recording rather than a source. */
720
+ function wireItems(value, what) {
721
+ return sourceItems(value, what, RECORDING_HINT);
722
+ }
723
+ /** The same, for a list of them: absent is empty, a non-array is a failed read. */
724
+ function sourceItems(value, what, hint = DISCOVERY_HINT) {
725
+ if (value === null || value === undefined)
726
+ return [];
727
+ if (!Array.isArray(value)) {
728
+ throw Errors.apiError(truncateErrorMessage(`${what} is ${describeWireValue(value)} rather than a list`), undefined, { retryable: false, hint });
729
+ }
730
+ return value.map((item) => sourceObject(item, `${what} entry`, hint));
731
+ }
732
+ /**
733
+ * The whole number a wire field carries, or a failed read — the TYPE half only.
734
+ *
735
+ * Shared because Go's decoders agree on it everywhere: absent and JSON null are
736
+ * the zero value, and anything that is not a whole number in an int64 fails the
737
+ * read. What is NOT shared is what each site then does with the number, and a
738
+ * helper that decided that too would be the tidy kind of wrong: a rule reading
739
+ * like the reference's while applying one site's policy at another's. Each
740
+ * caller spells its own value rule beside the Go line it mirrors.
741
+ */
742
+ const INT64_LIMIT = 2 ** 63;
743
+ function wireInteger(value, what, hint = DISCOVERY_HINT) {
744
+ if (value === undefined || value === null)
745
+ return 0;
746
+ // The window is Go's int64, measured at both ends: 9223372036854775807 and
747
+ // -9223372036854775808 decode, and one past either is a decode error. An
748
+ // "any whole number" rule was wider than the reference at the top end, so a
749
+ // bucket id of 1e20 — a body Go refuses — came back as a bucket_mismatch
750
+ // naming a bucket that cannot exist.
751
+ //
752
+ // MEASURED IN GO, NOT IN THIS RUNTIME, and the two differ at exactly one
753
+ // point: `JSON.parse("9223372036854775807")` is 2^63 here, so the top of the
754
+ // window does NOT survive the parse and this guard refuses it. The negative
755
+ // end does survive. That over-refusal is reachable at one literal value, in
756
+ // the refusing direction, and it is declared in the TypeScript row of
757
+ // Appendix F rather than left for a reader to discover from this sentence
758
+ // asserting the opposite.
759
+ if (typeof value !== "number" || !Number.isInteger(value) || value >= INT64_LIMIT || value < -INT64_LIMIT) {
760
+ throw Errors.apiError(truncateErrorMessage(`${what} has an id that is not a usable whole number (${describeIdValue(value)})`), undefined, { retryable: false, hint });
761
+ }
762
+ return value;
763
+ }
764
+ /**
765
+ * The id a discovery source carried, read the way Go's decoder reads it.
766
+ *
767
+ * Three outcomes, and the first version of this had two of them wrong.
768
+ * `json.Unmarshal` into an `int64` treats an ABSENT key and a JSON `null` as
769
+ * the zero value and succeeds — Go then skips a zero id at its own filter — so
770
+ * neither is an error here either. A string, a fraction, or a number past
771
+ * int64 fails Go's decode outright and takes the whole read with it, which is
772
+ * the malformed-response shape SPEC §6 spells for a composite that cannot
773
+ * proceed with a 2xx body: `api_error`, statusless, non-retryable.
774
+ *
775
+ * Getting the first two wrong turned "skip this dock entry" into a failure of
776
+ * the entire summarize; getting the last wrong let a fractional id through to
777
+ * a request for `/chats/1.5/lines/{id}`.
778
+ *
779
+ * TWO GAPS, both stated rather than papered over. Go refuses the LITERAL — its
780
+ * decoder runs `strconv.ParseInt` over the raw token — so `1.0`, `1e2` and
781
+ * `7.7e1` are decode errors there. By the time this runs, `JSON.parse` has
782
+ * turned every one of those into the number 77, and the literal is gone: an
783
+ * exponent or trailing-zero spelling of a whole number is accepted here and
784
+ * refused there — `1.0` arrives as 1, `1e2` as 100, `7.7e1` as 77 — and this
785
+ * layer, which receives an already-parsed value, cannot tell any of them from
786
+ * the literal Go refuses. Not an impossibility, a placement: on the supported
787
+ * Node floor a `JSON.parse` reviver can read `context.source` and see the
788
+ * spelling (SPEC §19 says so for the large-integer case), so a decoder that
789
+ * wanted these distinctions could have them. This composite does not reach for
790
+ * one, because it would mean parsing every response body twice to refuse a
791
+ * spelling no BC3 endpoint emits. And
792
+ * the upper bound is 2^53, not int64, which is the same limit SPEC §19 waives for this SDK: an id
793
+ * past it cannot be held without rounding it into a different id, so refusing
794
+ * it is the honest answer even though Go decodes it.
795
+ *
796
+ * The int64 window's own two ends are not symmetric after rounding, and the
797
+ * message says so rather than pretending otherwise: 9223372036854775808 rounds
798
+ * to exactly 2^63 and is refused, while -9223372036854775809 — a decode error
799
+ * in Go — rounds to exactly -2^63, a legal value, and nothing at this layer can
800
+ * tell the two apart. One end is measurable after JSON.parse and the other is
801
+ * not.
802
+ */
803
+ function numericId(value, what, hint = DISCOVERY_HINT) {
804
+ const id = wireInteger(value, what, hint);
805
+ if (!Number.isSafeInteger(id)) {
806
+ throw Errors.apiError(truncateErrorMessage(`${what} has an id that is not a usable whole number (${describeIdValue(value)})`), undefined, { retryable: false, hint });
807
+ }
808
+ return id;
809
+ }
810
+ /**
811
+ * What a wire value IS, for a site that was not reading an id.
812
+ *
813
+ * `describeIdValue` speaks about ids — "out of safe integer range", "not whole"
814
+ * — and reusing it for containers and text produced messages that send a reader
815
+ * hunting for the wrong thing: "the project dock is out of safe integer range
816
+ * rather than a list" for `{"dock": 5}`.
817
+ */
818
+ function describeWireValue(value) {
819
+ if (value === null)
820
+ return "null";
821
+ if (Array.isArray(value))
822
+ return "an array";
823
+ if (typeof value === "string")
824
+ return JSON.stringify(value);
825
+ if (typeof value === "object")
826
+ return "an object";
827
+ return `${typeof value} ${String(value)}`;
828
+ }
829
+ function describeIdValue(value) {
830
+ if (typeof value === "string")
831
+ return JSON.stringify(value);
832
+ // `typeof []` is "object", which names neither of the two shapes a caller
833
+ // would have to tell apart to fix the response.
834
+ if (Array.isArray(value))
835
+ return "an array";
836
+ if (typeof value === "number") {
837
+ // Two different complaints, and the first version made both of them the
838
+ // second: 1.5 is perfectly in range and was carried faithfully — what is
839
+ // wrong with it is that it is not whole. A value past 2^53 is the other
840
+ // case, and there the number has already been rounded by JSON.parse, so
841
+ // quoting it would report something the response never carried.
842
+ if (Number.isInteger(value))
843
+ return "out of safe integer range";
844
+ return `${value}, which is not whole`;
845
+ }
846
+ return typeof value;
847
+ }
848
+ /**
849
+ * Whether an error is a read's own 404 — the one answer discovery treats as
850
+ * "not here".
851
+ *
852
+ * The status is required, not just the code. Go reaches this through a
853
+ * `*basecamp.Error` whose `CodeNotFound` can only have come from a 404
854
+ * response; here `not_found` is also the code {@link UnresolvedRecordingError}
855
+ * carries, deliberately statusless. Matching on the code alone would let a
856
+ * composite's own verdict — thrown by a hook, a middleware, or a caller-supplied
857
+ * read source — be read as "the line is not in this Campfire", and discovery
858
+ * would move on and eventually report the line unresolved instead of surfacing
859
+ * it. Every `not_found` the transport produces carries the status.
860
+ */
861
+ function isNotFound(err) {
862
+ return isBasecampError(err) && err.code === "not_found" && err.httpStatus === 404;
863
+ }
864
+ /** One `summarize` call's discovery state. */
865
+ class ChatLineSearch {
866
+ sources;
867
+ lineId;
868
+ /** Candidates that answered 404, in order. */
869
+ tried = [];
870
+ /** Candidates still allowed. */
871
+ budget = MAX_CAMPFIRE_CANDIDATES;
872
+ /** Whether a candidate was left untried for want of budget. */
873
+ skipped = false;
874
+ constructor(sources, lineId) {
875
+ this.sources = sources;
876
+ this.lineId = lineId;
877
+ }
878
+ /**
879
+ * Reads the line under each candidate not yet tried. Returns the line and its
880
+ * Campfire on a hit; on a miss returns `undefined` and records the candidates
881
+ * in {@link tried}. Any answer but 404 is raised as is.
882
+ */
883
+ async try(candidates) {
884
+ for (const campfireId of candidates) {
885
+ if (this.tried.includes(campfireId))
886
+ continue;
887
+ if (this.budget <= 0) {
888
+ this.skipped = true;
889
+ return undefined;
890
+ }
891
+ this.budget--;
892
+ try {
893
+ const line = await this.sources.campfires.getLine(campfireId, this.lineId);
894
+ return { line, campfireId };
895
+ }
896
+ catch (err) {
897
+ if (isNotFound(err)) {
898
+ this.tried.push(campfireId);
899
+ continue;
900
+ }
901
+ throw err;
902
+ }
903
+ }
904
+ return undefined;
905
+ }
906
+ }
907
+ // =============================================================================
908
+ // Service
909
+ // =============================================================================
910
+ /**
911
+ * `RecordingsService` with the hand-written `summarize` composite on top of the
912
+ * generated surface (`list`, `trash`, ...).
913
+ *
914
+ * The composite reaches its sibling reads through the client that built it, so
915
+ * every request is the same public generated method a caller would have made
916
+ * and hooks see each under its own operation identity.
917
+ */
918
+ export class RecordingsService extends GeneratedRecordingsService {
919
+ #sources;
920
+ #campfires;
921
+ // Preserve BaseService's full positional signature, then append the read
922
+ // sources — the shape uploads-extensions established, so an existing
923
+ // `new RecordingsService(client, hooks, fetchPage, maxPages)` call keeps
924
+ // working and `summarize()` explains itself when the sources were not wired.
925
+ constructor(client, hooks, fetchPage, maxPages, authenticatedFetch, baseUrl, sources, now) {
926
+ super(client, hooks, fetchPage, maxPages, authenticatedFetch, baseUrl);
927
+ this.#sources = sources;
928
+ // The index lives on the service, which the client memoizes: the dock and
929
+ // listing snapshots are shared by every summarize() through one client and
930
+ // by nothing across two, which is the scope Go gives them on its Client.
931
+ this.#campfires = new CampfireIndex(now);
932
+ }
933
+ /**
934
+ * Resolves a recording pointer into a {@link RecordingSummary} through the
935
+ * typed read its type names. See {@link RecordingRef} for the routing inputs.
936
+ *
937
+ * Errors: a routing failure (`no_recording_type` / `unknown_recording_type`)
938
+ * before any request; the read's own {@link BasecampError} otherwise — a 404
939
+ * is `not_found`, as from the typed read itself; for chat lines,
940
+ * {@link UnresolvedRecordingError} when every visible Campfire answered 404,
941
+ * which is distinct from a read that failed (any non-404 from a candidate is
942
+ * raised as that error, and the loop stops there) and from
943
+ * {@link CampfireDiscoveryIncompleteError} (candidates were left unsearched);
944
+ * {@link BucketMismatchError} when the read returned a recording from another
945
+ * bucket.
946
+ *
947
+ * @example
948
+ * ```ts
949
+ * const summary = await client.recordings.summarize({
950
+ * bucketId: 2085958499,
951
+ * recordingId: 1069479361,
952
+ * eventType: "comment.created",
953
+ * });
954
+ * summary.mentioned_person_ids; // [1049715915]
955
+ * ```
956
+ */
957
+ async summarize(ref) {
958
+ // `isSafeInteger`, not `isInteger`, and for two different reasons — one per
959
+ // id, both worth stating because a reader checking only the first would
960
+ // think the second was along for the ride.
961
+ //
962
+ // `recordingId` is ADDRESSED: it is interpolated into every read this
963
+ // composite makes, so a rounded value fetches a DIFFERENT recording and
964
+ // reports it as the one asked for. That is the same rule `numericId`
965
+ // applies to a Campfire id, at the other end of the same URL.
966
+ //
967
+ // `bucketId` is COMPARED — the listing's bucket filter and the cross-project
968
+ // check both weigh it against a number that arrived through JSON.parse, and
969
+ // two int64 buckets can round onto one double, which is how a recording from
970
+ // another project passed as a match — AND addressed, since chat-line
971
+ // discovery reads `/projects/{bucketId}`. An earlier version of this
972
+ // paragraph split the two ids by those two words, one each, in a commit
973
+ // whose whole point was that a reason had covered only half of a check. It
974
+ // covered half again.
975
+ //
976
+ // Go accepts both — its ids are int64 — so this is a refusal the reference
977
+ // does not make, and it is this port's, not a reading of SPEC waiver 1B.6:
978
+ // the waiver is about decoding ids the API sends, and says nothing about
979
+ // caller input. A `usage` error because the caller supplied the value, and
980
+ // a refusal rather than a silent rounding because the alternative is
981
+ // answering about a recording nobody asked for.
982
+ if (!Number.isSafeInteger(ref.bucketId) ||
983
+ ref.bucketId <= 0 ||
984
+ !Number.isSafeInteger(ref.recordingId) ||
985
+ ref.recordingId <= 0) {
986
+ throw Errors.usage("bucket id and recording id are required");
987
+ }
988
+ const kind = routeRecording(ref);
989
+ const summary = await this.#readSummary(ref, kind);
990
+ // A bucket the read did not identify is Go's zero value: there is nothing
991
+ // to disagree with, so the pointer stands rather than the call failing.
992
+ // The bucket itself, not only its id: a string or an array where the object
993
+ // belongs leaves `?.id` undefined, and "no bucket identified" is exactly the
994
+ // answer that lets a recording in another project pass as a match. Go fails
995
+ // the read on that body (measured: a string and an array are both decode
996
+ // errors into the bucket struct; a null is the zero value, hence absent).
997
+ // `"bucket" in summary`, not `!== undefined`: the projection assigns this
998
+ // key on truthiness, so `"bucket": 0`, `false` and `""` never became a key
999
+ // at all and slipped past a guard that asked whether the key was undefined.
1000
+ // Each of them is a decode error in Go, and each read here as "no bucket
1001
+ // identified" — the answer that lets a recording in another project pass as
1002
+ // a match, arrived at through the one door the first guard left open.
1003
+ const readBucket = summary.bucket;
1004
+ if (readBucket !== undefined && (readBucket === null || typeof readBucket !== "object" || Array.isArray(readBucket))) {
1005
+ throw Errors.apiError(truncateErrorMessage(`the recording read identified its bucket as ${describeWireValue(readBucket)}`), undefined, {
1006
+ retryable: false,
1007
+ hint: "the response is malformed; whether the recording is in the requested project cannot be decided from it",
1008
+ });
1009
+ }
1010
+ // And the id, through the same gate as the three discovery sites — this one
1011
+ // reached it last. It began as `!== undefined`, which read a malformed id as
1012
+ // "no bucket"; then `typeof`, which still compared a FRACTION and could
1013
+ // report the recording as living in bucket 1.5; then `isInteger`, which
1014
+ // stopped fabricating that verdict but still answered "no bucket
1015
+ // identified" for a string, a boolean, an array or an object — and that
1016
+ // answer is the one that lets a recording in ANOTHER project pass as a
1017
+ // match. Go refuses every one of those bodies outright, and `numericId`
1018
+ // says exactly that: null and absent are its zero, which is skipped;
1019
+ // anything else that is not a whole number in range fails the read.
1020
+ // Go's line is `summary.Bucket != nil && summary.Bucket.ID != 0 &&
1021
+ // summary.Bucket.ID != ref.BucketID` — the type half from its decoder, then
1022
+ // a value rule of exactly "not zero, not the one asked for". `wireInteger`,
1023
+ // not `numericId`: the safe-integer clause belongs to the sites that BUILD
1024
+ // A URL from the id, where a rounded value addresses a different Campfire.
1025
+ // Nothing is addressed here; the id is only compared.
1026
+ //
1027
+ // An argument for comparing rounded ids stood here until it was deleted.
1028
+ // Not softened and not quoted: it argued for the behaviour two lines below
1029
+ // it, and a reader weighing prose against code would have believed the
1030
+ // prose. What replaced it lives in `summarize`, which refuses a caller id
1031
+ // this runtime cannot hold exactly.
1032
+ const readBucketId = wireInteger(summary.bucket?.id, "the recording read's bucket", RECORDING_HINT);
1033
+ // `!Number.isSafeInteger` is a MISMATCH, not a pass — and, like the listing
1034
+ // filter, unreachable while `ref.bucketId` must itself be a safe integer,
1035
+ // since a rounded value above 2^53 cannot equal one below it. The fix for
1036
+ // the defect is the check in `summarize`, not this clause; this states the
1037
+ // same fact where the comparison happens, and a revert of it is
1038
+ // behaviour-neutral.
1039
+ //
1040
+ // The defect: this used to compare the rounded values, with a comment
1041
+ // arguing they "round identically, so the comparison still means
1042
+ // something". It does not. Rounding is not injective, and a recording from
1043
+ // bucket 2^53+1 came back as a MATCH for a request against 2^53 — the one
1044
+ // thing this check exists to prevent, argued for in prose.
1045
+ if (readBucketId !== 0 && (!Number.isSafeInteger(readBucketId) || readBucketId !== ref.bucketId)) {
1046
+ throw new BucketMismatchError(ref, readBucketId);
1047
+ }
1048
+ return summary;
1049
+ }
1050
+ /** The sources, or a usage error naming how to obtain a wired service. */
1051
+ get #reads() {
1052
+ const sources = this.#sources?.();
1053
+ if (sources === undefined) {
1054
+ throw Errors.usage("recordings.summarize composes the other services' reads — obtain RecordingsService via createBasecampClient(...).recordings rather than instantiating it directly");
1055
+ }
1056
+ return sources;
1057
+ }
1058
+ /** Performs the one typed read a kind names and projects it. */
1059
+ async #readSummary(ref, kind) {
1060
+ const reads = this.#reads;
1061
+ const id = ref.recordingId;
1062
+ switch (kind) {
1063
+ case "comment": {
1064
+ const c = recordingBody(await reads.comments.get(id));
1065
+ return projectRecording(c, c.title, c.content, { parent: c.parent, bucket: c.bucket, creator: c.creator });
1066
+ }
1067
+ case "message": {
1068
+ const m = recordingBody(await reads.messages.get(id));
1069
+ return projectRecording(m, firstNonEmpty("a message title", m.title, m.subject), m.content, {
1070
+ parent: m.parent,
1071
+ bucket: m.bucket,
1072
+ creator: m.creator,
1073
+ });
1074
+ }
1075
+ case "todo": {
1076
+ // A to-do's content is its plain title; the rich text — where mentions
1077
+ // live — is the description.
1078
+ const t = recordingBody(await reads.todos.get(id));
1079
+ return projectRecording(t, firstNonEmpty("a recording title", t.title, t.content), t.description, {
1080
+ parent: t.parent,
1081
+ bucket: t.bucket,
1082
+ creator: t.creator,
1083
+ assignees: t.assignees,
1084
+ // A todo carries a TodoParent, which has no bucket field at all.
1085
+ parentHasBucket: false,
1086
+ });
1087
+ }
1088
+ case "card": {
1089
+ const c = recordingBody(await reads.cards.get(id));
1090
+ return projectRecording(c, c.title, firstNonEmpty("a recording rich text", c.content, c.description), {
1091
+ parent: c.parent,
1092
+ bucket: c.bucket,
1093
+ creator: c.creator,
1094
+ assignees: c.assignees,
1095
+ });
1096
+ }
1097
+ case "chatLine": {
1098
+ const { line: rawLine, campfireId } = await this.#resolveChatLine(ref.bucketId, id);
1099
+ const line = recordingBody(rawLine);
1100
+ const summary = projectRecording(line, line.title, line.content, {
1101
+ parent: line.parent,
1102
+ bucket: line.bucket,
1103
+ creator: line.creator,
1104
+ });
1105
+ if (!chatLineIsRichText(line.type)) {
1106
+ // A plain-text or code line's content is text BC3 never read as
1107
+ // markup, so a literal "<bc-attachment>" in it mentions nobody.
1108
+ summary.mentioned_person_ids = [];
1109
+ // And no unnameable ones either: the rich-text walk ran over `content`
1110
+ // before this branch knew the line was not rich text, so it may have
1111
+ // found a "mention" here. There is none -- this text mentions nobody,
1112
+ // in Go and here -- and leaving the key would report a short list
1113
+ // where there is no list at all.
1114
+ delete summary.unnameable_mention_ids;
1115
+ }
1116
+ // Omitted when zero, because Go's field is `omitempty` and a zero
1117
+ // marshals away there. A zero is reachable: the account listing keeps
1118
+ // an entry whose own id is null as a candidate (Go filters that source
1119
+ // on the bucket alone), so a Campfire 0 that answers 200 resolves the
1120
+ // line — and only then do the two SDKs' JSON differ by a key.
1121
+ if (campfireId !== 0)
1122
+ summary.campfire_id = campfireId;
1123
+ return summary;
1124
+ }
1125
+ case "document": {
1126
+ const d = recordingBody(await reads.documents.get(id));
1127
+ return projectRecording(d, d.title, d.content, { parent: d.parent, bucket: d.bucket, creator: d.creator });
1128
+ }
1129
+ case "upload": {
1130
+ const u = recordingBody(await reads.uploads.get(id));
1131
+ return projectRecording(u, firstNonEmpty("an upload title", u.title, u.filename), u.description, {
1132
+ parent: u.parent,
1133
+ bucket: u.bucket,
1134
+ creator: u.creator,
1135
+ });
1136
+ }
1137
+ case "scheduleEntry": {
1138
+ const e = recordingBody(await reads.schedules.getEntry(id));
1139
+ return projectRecording(e, firstNonEmpty("a recording title", e.title, e.summary), e.description, {
1140
+ parent: e.parent,
1141
+ bucket: e.bucket,
1142
+ creator: e.creator,
1143
+ });
1144
+ }
1145
+ case "question": {
1146
+ const q = recordingBody(await reads.checkins.getQuestion(id));
1147
+ return projectRecording(q, q.title, "", { parent: q.parent, bucket: q.bucket, creator: q.creator });
1148
+ }
1149
+ case "questionAnswer": {
1150
+ const a = recordingBody(await reads.checkins.getAnswer(id));
1151
+ return projectRecording(a, a.title, a.content, { parent: a.parent, bucket: a.bucket, creator: a.creator });
1152
+ }
1153
+ case "todolist": {
1154
+ const l = recordingBody(await reads.todolists.get(id));
1155
+ return projectRecording(l, firstNonEmpty("a recording title", l.title, l.name), l.description, {
1156
+ parent: l.parent,
1157
+ bucket: l.bucket,
1158
+ creator: l.creator,
1159
+ // A todolist carries a TodoParent too — same shape, same absence.
1160
+ parentHasBucket: false,
1161
+ });
1162
+ }
1163
+ case "vault": {
1164
+ const v = recordingBody(await reads.vaults.get(id));
1165
+ return projectRecording(v, v.title, "", { parent: v.parent, bucket: v.bucket, creator: v.creator });
1166
+ }
1167
+ case "forward": {
1168
+ const f = recordingBody(await reads.forwards.get(id));
1169
+ return projectRecording(f, firstNonEmpty("a message title", f.title, f.subject), f.content, {
1170
+ parent: f.parent,
1171
+ bucket: f.bucket,
1172
+ creator: f.creator,
1173
+ });
1174
+ }
1175
+ case "clientApproval": {
1176
+ const a = recordingBody(await reads.clientApprovals.get(id));
1177
+ return projectRecording(a, firstNonEmpty("a message title", a.title, a.subject), a.content, {
1178
+ parent: a.parent,
1179
+ bucket: a.bucket,
1180
+ creator: a.creator,
1181
+ });
1182
+ }
1183
+ case "clientCorrespondence": {
1184
+ const c = recordingBody(await reads.clientCorrespondences.get(id));
1185
+ return projectRecording(c, firstNonEmpty("a message title", c.title, c.subject), c.content, {
1186
+ parent: c.parent,
1187
+ bucket: c.bucket,
1188
+ creator: c.creator,
1189
+ });
1190
+ }
1191
+ case "googleDocument": {
1192
+ const g = recordingBody(await reads.googleDocuments.googleDocument(id));
1193
+ return projectRecording(g, g.title, g.description, { parent: g.parent, bucket: g.bucket, creator: g.creator });
1194
+ }
1195
+ case "cloudFile": {
1196
+ const f = recordingBody(await reads.cloudFiles.cloudFile(id));
1197
+ return projectRecording(f, f.title, f.description, { parent: f.parent, bucket: f.bucket, creator: f.creator });
1198
+ }
1199
+ case "cardStep": {
1200
+ const s = recordingBody(await reads.cardSteps.get(id));
1201
+ return projectRecording(s, s.title, "", {
1202
+ parent: s.parent,
1203
+ bucket: s.bucket,
1204
+ creator: s.creator,
1205
+ assignees: s.assignees,
1206
+ });
1207
+ }
1208
+ case "questionnaire": {
1209
+ const q = recordingBody(await reads.checkins.getQuestionnaire(id));
1210
+ return projectRecording(q, firstNonEmpty("a recording title", q.title, q.name), "", { bucket: q.bucket, creator: q.creator });
1211
+ }
1212
+ case "schedule": {
1213
+ const s = recordingBody(await reads.schedules.get(id));
1214
+ return projectRecording(s, s.title, "", { bucket: s.bucket, creator: s.creator });
1215
+ }
1216
+ case "todoset": {
1217
+ const t = recordingBody(await reads.todosets.get(id));
1218
+ return projectRecording(t, firstNonEmpty("a recording title", t.title, t.name), "", { bucket: t.bucket, creator: t.creator });
1219
+ }
1220
+ case "messageBoard": {
1221
+ const b = recordingBody(await reads.messageBoards.get(id));
1222
+ return projectRecording(b, b.title, "", { bucket: b.bucket, creator: b.creator });
1223
+ }
1224
+ case "cardTable": {
1225
+ const t = recordingBody(await reads.cardTables.get(id));
1226
+ return projectRecording(t, t.title, "", { bucket: t.bucket, creator: t.creator });
1227
+ }
1228
+ case "cardColumn": {
1229
+ const c = recordingBody(await reads.cardColumns.get(id));
1230
+ return projectRecording(c, c.title, c.description, {
1231
+ parent: c.parent,
1232
+ bucket: c.bucket,
1233
+ creator: c.creator,
1234
+ });
1235
+ }
1236
+ case "inbox": {
1237
+ const i = recordingBody(await reads.forwards.getInbox(id));
1238
+ return projectRecording(i, i.title, "", { bucket: i.bucket, creator: i.creator });
1239
+ }
1240
+ case "campfire": {
1241
+ const c = recordingBody(await reads.campfires.get(id));
1242
+ return projectRecording(c, c.title, "", { bucket: c.bucket, creator: c.creator });
1243
+ }
1244
+ default:
1245
+ // Unreachable for a routed kind — the switch is exhaustive over
1246
+ // SummaryKind — and kept anyway, as Go keeps its trailing routing
1247
+ // error. Without it a value that reached here outside the type system
1248
+ // would fall out of the switch as `undefined` and be dereferenced as a
1249
+ // summary, turning a routing failure into a TypeError.
1250
+ throw new RecordingRoutingError(ref, "unknown_recording_type");
1251
+ }
1252
+ }
1253
+ /**
1254
+ * Finds the Campfire a line lives in and reads it.
1255
+ *
1256
+ * A `chat.line.created` row carries the line's id and bucket, not its
1257
+ * Campfire, and the line read is `/chats/{campfireId}/lines/{lineId}`.
1258
+ * Candidates come from two sources, tried in order and each cached ten
1259
+ * minutes:
1260
+ *
1261
+ * 1. The bucket's project dock, whose `chat` tool is the project's Campfire:
1262
+ * one project read per bucket, and the answer for every line posted in a
1263
+ * project. Cached per bucket.
1264
+ * 2. The account-wide Campfire listing (BC3 has no per-bucket one), filtered
1265
+ * to the bucket, for buckets that are not projects or whose dock did not
1266
+ * hold the line. Cached per account, so a burst of lines costs one
1267
+ * listing.
1268
+ *
1269
+ * The loop tries the line under each candidate until one answers, within one
1270
+ * total budget of {@link MAX_CAMPFIRE_CANDIDATES} per call.
1271
+ *
1272
+ * Two failure shapes are kept apart on purpose. A candidate that answers
1273
+ * anything but 404 — 401, 403, 5xx, a network error — stops the loop and is
1274
+ * raised as that error: the read failed, and trying the next Campfire would
1275
+ * only hide it. A 404 means "not here", so the loop moves on. Only when every
1276
+ * candidate said "not here" is the line unresolved
1277
+ * ({@link UnresolvedRecordingError}) — and before concluding that, the cached
1278
+ * sources are refreshed (subject to the floor) so a Campfire created after
1279
+ * the cache filled is tried too. Discovery that could not be completed — a
1280
+ * listing cut off at its cap, a bucket with more candidates than the budget —
1281
+ * is {@link CampfireDiscoveryIncompleteError}, never "unresolved": nothing
1282
+ * unsearched is ever reported absent — which is why a spent budget is read
1283
+ * against WHICH sources it was spent before: one already consulted is simply
1284
+ * not re-read, while one never reached leaves candidates unsearched and makes
1285
+ * the verdict incomplete.
1286
+ *
1287
+ * What HTTP cannot tell apart: BC3 answers 404 both for a line that is not in
1288
+ * a Campfire and for a Campfire the caller may no longer see. "Unresolved"
1289
+ * therefore means "under no Campfire the caller can currently see", and the
1290
+ * error reports the cached candidates that the refreshed sources no longer
1291
+ * list ({@link UnresolvedRecordingError.staleCampfireIds}) so a consumer can
1292
+ * see when visibility, not existence, is what changed.
1293
+ */
1294
+ async #resolveChatLine(bucketId, lineId) {
1295
+ const reads = this.#reads;
1296
+ const index = this.#campfires;
1297
+ const search = new ChatLineSearch(reads, lineId);
1298
+ const incomplete = (reason) => new CampfireDiscoveryIncompleteError({ bucketId, recordingId: lineId, reason });
1299
+ // Pass 1: what the sources already hold — the dock (read if it must be),
1300
+ // then the listing only if it is cached. A listing fetch is the expensive,
1301
+ // slow request, and it is not made until the dock — including its refresh —
1302
+ // has had its say, so a listing that is down or over its cap never stands
1303
+ // between a project's line and the one project read that finds it.
1304
+ let dock = await index.dockCampfires(reads, bucketId, false);
1305
+ const fromDock = await search.try(dock.ids);
1306
+ if (fromDock !== undefined)
1307
+ return fromDock;
1308
+ let listed = index.cachedListedCampfires(bucketId);
1309
+ const listCached = listed !== undefined;
1310
+ if (listed !== undefined) {
1311
+ const fromCachedListing = await search.try(listed.ids);
1312
+ if (fromCachedListing !== undefined)
1313
+ return fromCachedListing;
1314
+ }
1315
+ // Pass 2: re-read the dock if it was served from cache (the floor may
1316
+ // decline), then fetch or refresh the listing. Whatever comes back is the
1317
+ // current snapshot of that source, whoever loaded it — another caller may
1318
+ // have populated or refreshed it in the meantime — so it always replaces
1319
+ // the pass-1 one; "refreshed" is whether a source the conclusion had
1320
+ // consulted is now newer than when it was consulted.
1321
+ //
1322
+ // Not when the budget is already spent: a re-read could return no candidate
1323
+ // this call may try, so it would cost a request that cannot help — and a
1324
+ // failure on it would replace the deterministic "incomplete" verdict with a
1325
+ // transient error a consumer retries forever.
1326
+ let refreshed = false;
1327
+ // No budget left means no re-read: a source already consulted cannot hand
1328
+ // this call a candidate it may try, so its refresh is skipped and the
1329
+ // conclusion stands on what was seen (refreshed false). A source NEVER
1330
+ // consulted is different — candidates may exist there unsearched — so
1331
+ // running out of budget before reaching it makes the verdict incomplete.
1332
+ //
1333
+ // Concluding incomplete on a spent budget unconditionally would be the
1334
+ // wrong fix: it would call a search that did examine both sources
1335
+ // unfinished. Concluding nothing would be the other wrong fix, and is what
1336
+ // this replaced — it fetched a listing that could not help, and a failure
1337
+ // on that fetch replaced a deterministic "incomplete" with a transient
1338
+ // error a consumer retries forever.
1339
+ if (search.budget > 0 && dock.cached) {
1340
+ const again = await index.dockCampfires(reads, bucketId, true);
1341
+ if (again.fetchedAt > dock.fetchedAt || !again.cached)
1342
+ refreshed = true;
1343
+ dock = again;
1344
+ const fromRefreshedDock = await search.try(dock.ids);
1345
+ if (fromRefreshedDock !== undefined)
1346
+ return fromRefreshedDock;
1347
+ }
1348
+ if (search.budget <= 0) {
1349
+ if (!listCached) {
1350
+ throw incomplete(`the candidate budget of ${MAX_CAMPFIRE_CANDIDATES} was spent before the account listing was consulted`);
1351
+ }
1352
+ }
1353
+ else {
1354
+ let again;
1355
+ try {
1356
+ again = await index.listedCampfires(reads, bucketId, listCached);
1357
+ }
1358
+ catch (err) {
1359
+ if (err instanceof CampfireListingOverflow)
1360
+ throw incomplete(err.message);
1361
+ throw err;
1362
+ }
1363
+ if (listed !== undefined && (again.fetchedAt > listed.fetchedAt || !again.cached)) {
1364
+ refreshed = true;
1365
+ }
1366
+ listed = again;
1367
+ const fromListing = await search.try(listed.ids);
1368
+ if (fromListing !== undefined)
1369
+ return fromListing;
1370
+ }
1371
+ if (search.skipped) {
1372
+ throw incomplete(`more than ${MAX_CAMPFIRE_CANDIDATES} visible campfires in the bucket`);
1373
+ }
1374
+ const listedIds = listed?.ids ?? [];
1375
+ const staleCampfireIds = refreshed
1376
+ ? search.tried.filter((id) => !dock.ids.includes(id) && !listedIds.includes(id))
1377
+ : [];
1378
+ throw new UnresolvedRecordingError({
1379
+ bucketId,
1380
+ recordingId: lineId,
1381
+ campfireIds: search.tried,
1382
+ refreshed,
1383
+ staleCampfireIds,
1384
+ });
1385
+ }
1386
+ }
1387
+ /** Builds the projection, keeping only the keys the recording actually has. */
1388
+ function projectRecording(recording, title, content, nested) {
1389
+ const body = recordingText(content, "a recording rich text");
1390
+ // The scalars default rather than carrying `undefined` through: they are
1391
+ // required on every routed shape, so a body omitting one is malformed, and
1392
+ // Go emits its zero value. Left undefined, JSON.stringify would drop the key
1393
+ // and a consumer would see a shape no other SDK produces. The three strings
1394
+ // default to ""; updated_at does not, because its Go type is not a string.
1395
+ const summary = {
1396
+ // `id` too, which the first pass at this missed even while the comment
1397
+ // above stated the rule: Go marshals a zero RecordingSummary as {"id":0,…}.
1398
+ // And through `wireInteger`, which the id sweep missed here while sweeping
1399
+ // the three discovery sites and every string field around it: this was the
1400
+ // one number field left on a bare `??`, so `{"id":"1"}` came back as the
1401
+ // STRING "1" in a field typed `number`, a shape no other SDK can produce.
1402
+ // `wireInteger`, not `numericId`: this id is emitted, never addressed.
1403
+ id: wireInteger(recording.id, "the recording", RECORDING_HINT),
1404
+ status: recordingText(recording.status, "a recording status"),
1405
+ type: recordingText(recording.type, "a recording type"),
1406
+ // The types that read `title` straight off the recording pass it through
1407
+ // undefined when the body omits it; the ones with a fallback already
1408
+ // resolve to "" through firstNonEmpty.
1409
+ title: recordingText(title, "a recording title"),
1410
+ app_url: recordingText(recording.app_url, "a recording app_url"),
1411
+ ...mentionsOf(body),
1412
+ content: body,
1413
+ // Go's zero value here is a `time.Time`, not a string, and it marshals as
1414
+ // the zero instant rather than as "".
1415
+ updated_at: recordingInstant(recording.updated_at),
1416
+ };
1417
+ // Each nested identity through the same gate as the bucket, and for the same
1418
+ // reason the sweep gave when it stopped at the bucket: measured through
1419
+ // generated.Comment and generated.Todo, a number, a string, a boolean or an
1420
+ // array in any of these slots is a decode error, while null and {} are the
1421
+ // zero value. Two of the four had no rule at all, three lines from the one
1422
+ // that did — the sweep stopped one field short of where it said it stopped.
1423
+ const parent = withEmptyLabel(nestedIdentity(nested.parent, "a recording parent", "parent", nested.parentHasBucket ?? true), "title");
1424
+ if (parent !== undefined)
1425
+ summary.parent = parent;
1426
+ // Assigned when PRESENT, not when truthy: a falsy non-object (0, false, "")
1427
+ // is a decode error in Go, and dropping the key here hid it from the
1428
+ // cross-project check, which then found nothing to disagree with. Its own
1429
+ // fields go through the same gate as every other member's; the id stays with
1430
+ // the cross-project check below, which is the one place it is INTERPRETED
1431
+ // rather than reported.
1432
+ if (nested.bucket !== undefined && nested.bucket !== null) {
1433
+ // The RETURN value, not the original: the bucket arm decodes its id the way
1434
+ // the parent arm does, and calling it for its refusals alone dropped the
1435
+ // conversion on the floor — so a null bucket id stayed `null` where Go and
1436
+ // Ruby both give 0. The first spelling of this line did exactly that.
1437
+ //
1438
+ // No nullish fallback, for the reason the assignees paragraph below gives:
1439
+ // the guard above has already excluded the only two inputs for which
1440
+ // `nestedIdentity` returns `undefined`, so a `?? nested.bucket` here was
1441
+ // unreachable — and it fell back to the ORIGINAL, the very thing the
1442
+ // sentence above says not to emit. A mutation replacing it with a cast left
1443
+ // the whole suite green, which is what an unreachable branch looks like.
1444
+ summary.bucket = withEmptyLabel(nestedIdentity(nested.bucket, "a recording bucket", "bucket"), "name");
1445
+ }
1446
+ const creator = withEmptyLabel(nestedIdentity(nested.creator, "a recording creator", "person"), "name");
1447
+ if (creator !== undefined)
1448
+ summary.creator = creator;
1449
+ // `assignees` is a LIST of them: Go refuses a non-array and a non-object
1450
+ // element, and takes null as the nil slice. Go's own `omitempty` is why an
1451
+ // empty list is dropped rather than emitted.
1452
+ // `wireItems` does the whole job: absent and null are the empty list, a
1453
+ // non-array fails the read, and each element goes through the same object
1454
+ // check the other three identities use. An earlier spelling wrapped every
1455
+ // element in `nestedIdentity` as well and credited the elementwise refusal to
1456
+ // it — that call could refuse nothing, having been handed an object already.
1457
+ const assignees = wireItems(nested.assignees, "a recording assignees list");
1458
+ // Each element through the SAME Person gate the creator takes. Go decodes
1459
+ // `[]Person`, so a wrong-typed `avatar_url` on the second assignee fails its
1460
+ // read exactly as it does on the first — and a rule applied to the creator
1461
+ // but not to the assignees would be the same inconsistency one level out.
1462
+ // Mapped rather than walked in place: the flexible id CONVERTS, so an element
1463
+ // whose id arrived as "007" has to be replaced by the decoded one.
1464
+ //
1465
+ // No nullish fallback on the result, and that is not an oversight: `wireItems`
1466
+ // has already mapped a null or absent element to `{}` (see `sourceObject`),
1467
+ // so nothing nullish reaches `nestedIdentity` and its `undefined` branch is
1468
+ // unreachable from here. A `?? assignee` here READ as if it were preserving a
1469
+ // null element Go emits as the zero Person, and preserved nothing — a guard
1470
+ // whose only effect was to make a reader believe a case was handled.
1471
+ const typedAssignees = assignees.map((assignee) => nestedIdentity(assignee, "a recording assignee", "person"));
1472
+ if (typedAssignees.length > 0)
1473
+ summary.assignees = typedAssignees;
1474
+ return summary;
1475
+ }
1476
+ function firstNonEmpty(what, ...values) {
1477
+ // EVERY candidate, not just the ones reached. Go's decoder refuses a
1478
+ // wrong-typed field whether or not the projection goes on to use it, so
1479
+ // `{"title":"T","subject":42}` fails the read there while a short-circuit
1480
+ // here returned "T" and never looked at the subject. Validate first, choose
1481
+ // after.
1482
+ const texts = values.map((value) => recordingText(value, what));
1483
+ for (const value of texts) {
1484
+ // A JSON null reads as Go's zero value and falls THROUGH to the next
1485
+ // candidate. Treating null as present is the quiet version of this defect:
1486
+ // {"title":null,"subject":"S"} decodes in Go with an empty title and takes
1487
+ // the subject, while here null passed the test, was returned, and the
1488
+ // caller's `?? ""` turned it into "" — the fallback lost, and a message
1489
+ // summarized under no title at all.
1490
+ if (value !== "")
1491
+ return value;
1492
+ }
1493
+ return "";
1494
+ }
1495
+ /** Go's zero `time.Time`, which is what it marshals for an absent instant. */
1496
+ const ZERO_INSTANT = "0001-01-01T00:00:00Z";
1497
+ // Measured against Go's decoder rather than read off RFC 3339, in three places
1498
+ // where it is wider than the grammar suggests: the fraction may be separated by
1499
+ // a COMMA as well as a dot (`…05,5Z` decodes, and re-marshals as `.5`); the
1500
+ // offset's range is wider than a clock, hour 00 to 24 and minute 00 to 60, with
1501
+ // 25 and 61 refused; and the HOUR is the one field Go parses at variable width
1502
+ // (`stdHour` calls `getnum` with `fixed=false`), so `T3:04:05Z` decodes and
1503
+ // re-marshals as `T03:04:05Z`. Measured alongside it: a single-digit month,
1504
+ // day, minute, second or offset component is refused, so `\d{1,2}` belongs to
1505
+ // the hour alone.
1506
+ const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{1,2}):(\d{2}):(\d{2})(?:[.,]\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/;
1507
+ /**
1508
+ * The instant a routed read carries, refused when it is not one.
1509
+ *
1510
+ * `updated_at` is the exception among the projected scalars, and treating it as
1511
+ * one more string got it wrong in both directions. Its Go type is `time.Time`,
1512
+ * whose `UnmarshalJSON` is stricter than a string's: a JSON null is the zero
1513
+ * instant and no error, but `""`, `"not-a-date"`, `"2024-01-02 03:04:05"`,
1514
+ * `"2024-02-30T…"` and a lowercase `t` or `z` are all decode errors. The
1515
+ * previous spelling — the string rule, then `|| ZERO_INSTANT` — painted `""`
1516
+ * with the zero instant, so a body the reference REFUSES came back carrying a
1517
+ * plausible timestamp instead of a visibly empty one.
1518
+ *
1519
+ * What this does not model is the re-rendering: Go parses and re-marshals, so
1520
+ * `2024-01-02T03:04:05.000Z` comes back as `…:05Z` there and verbatim here, and
1521
+ * `T3:04:05Z` as `T03:04:05Z`. The conformance runner compares instants rather
1522
+ * than strings — but only for the forms its own regex admits, and these are not
1523
+ * all of them. Six FAMILIES of accepted spelling render differently here than in
1524
+ * Go, counted by rule and measured one spelling at a time, because the previous
1525
+ * two attempts at this list both counted a rule by one of its examples:
1526
+ *
1527
+ * - **the fraction, normalized** — not "a comma becomes a dot", which is how
1528
+ * this was written and is one example of it. Go re-renders `.500Z` as `.5Z`,
1529
+ * `.000000000Z` as `Z`, and `,50Z` as `.5Z`: trailing zeros go, an all-zero
1530
+ * fraction goes entirely, and the separator is incidental;
1531
+ * - a single-digit hour (`T3:`), re-rendered `T03:`, with or without an offset;
1532
+ * - an offset hour of 24 (`+24:00`, and its twin `-24:00`), which Go's ENCODER
1533
+ * then refuses outright, its own range being [0,23];
1534
+ * - an offset minute of 60 that stays within the hour (`+00:60` → `+01:00`);
1535
+ * - an offset minute of 60 that CARRIES (`+23:60` → hour 24), which Go decodes
1536
+ * and then cannot marshal at all;
1537
+ * - a zero offset (`+00:00`, `-00:00`), which Go re-renders as `Z`.
1538
+ *
1539
+ * Where each one lands in the runner differs, and "all of them fall back to
1540
+ * exact string comparison" — the previous claim — is false for two. `T3:` and a
1541
+ * comma fail `sameInstant`'s regex; the offset-24 and carry forms match it and
1542
+ * fail its `Date.parse` guard; a normalized fraction reaches its
1543
+ * `fractionalDigits` comparison, which is the one that would actually FAIL a
1544
+ * fixture carrying `.500Z`; and a zero offset compares EQUAL, so that family is
1545
+ * genuinely invisible rather than merely unexercised.
1546
+ *
1547
+ * The zero instant is not on this list and was: `0001-01-01T00:00:00Z` is what
1548
+ * Go emits and what this returns, it round-trips identically, and it passes the
1549
+ * runner's regex, its `isRealTimestamp` year clause and `Date.parse`. A list of
1550
+ * things that differ is the wrong place for the one value both sides agree on. Nothing in the fixture carries one — checked, every
1551
+ * `updated_at` there is canonical — so the difference is invisible in practice
1552
+ * rather than by construction.
1553
+ *
1554
+ * Accepting them is still right: refusing a body the reference reads is the
1555
+ * worse error, and this composite reports what the API served. The leniency is
1556
+ * the stdlib's own — `format_rfc3339.go` disables strict parsing behind a
1557
+ * `case true:` and a TODO about a GODEBUG opt-out — so if that lands, these
1558
+ * become decode errors there and this rule follows the measurement again.
1559
+ */
1560
+ function recordingInstant(value) {
1561
+ if (value === undefined || value === null)
1562
+ return ZERO_INSTANT;
1563
+ const text = recordingText(value, "a recording updated_at");
1564
+ const parts = RFC3339.exec(text);
1565
+ if (parts !== null) {
1566
+ const year = Number(parts[1]);
1567
+ const month = Number(parts[2]);
1568
+ const day = Number(parts[3]);
1569
+ const hour = Number(parts[4]);
1570
+ const minute = Number(parts[5]);
1571
+ const second = Number(parts[6]);
1572
+ const offsetHour = parts[7] === undefined ? 0 : Number(parts[7]);
1573
+ const offsetMinute = parts[8] === undefined ? 0 : Number(parts[8]);
1574
+ // The shape is not enough: 2024-02-30 and 25:04 match it and Go refuses
1575
+ // them. A UTC round-trip is what rejects a date the calendar does not have
1576
+ // — with `setUTCFullYear` after it, because `Date.UTC(1, …)` means 1901,
1577
+ // not year 1, and the two-digit-year mapping made this refuse
1578
+ // "0001-01-01T00:00:00Z": THE ZERO INSTANT, the value Go marshals for an
1579
+ // absent time and the one this very function returns for a null. It was not
1580
+ // idempotent on its own output.
1581
+ const at = new Date(Date.UTC(2000, month - 1, day, hour, minute, second));
1582
+ at.setUTCFullYear(year);
1583
+ if (at.getUTCFullYear() === year &&
1584
+ at.getUTCMonth() === month - 1 &&
1585
+ at.getUTCDate() === day &&
1586
+ at.getUTCHours() === hour &&
1587
+ at.getUTCMinutes() === minute &&
1588
+ at.getUTCSeconds() === second &&
1589
+ offsetHour <= 24 &&
1590
+ offsetMinute <= 60) {
1591
+ return text;
1592
+ }
1593
+ }
1594
+ throw Errors.apiError(truncateErrorMessage(`a recording updated_at is ${describeWireValue(value)} rather than an RFC 3339 instant`), undefined, { retryable: false, hint: "the response is malformed; the recording cannot be summarized from it" });
1595
+ }
1596
+ /**
1597
+ * The fields of a nested member the reference types, by the model it decodes
1598
+ * into.
1599
+ *
1600
+ * TRANSCRIBED FROM `generated`, NOT FROM `pkg/basecamp`, and the difference is
1601
+ * load-bearing rather than pedantic. The Go service wrapper the composite calls
1602
+ * hands the body to the GENERATED client and converts afterwards
1603
+ * (`commentFromGenerated`), so the decode — the thing that fails a read —
1604
+ * happens against the generated model. The two disagree in exactly the two
1605
+ * places that would have bitten: `system_label` is a field of the hand-written
1606
+ * Person and NOT of the generated one, so it is an unknown key the reference
1607
+ * IGNORES and typing it here would refuse a body the reference accepts; and a
1608
+ * person's id is `FlexibleInt64` there against a plain `int64` here.
1609
+ *
1610
+ * `created_at` / `updated_at` are `*time.Time` in Go: a non-string fails on
1611
+ * both sides, and a string that is not RFC 3339 fails only there. That residue
1612
+ * is declared rather than closed, alongside the recording's own unread
1613
+ * top-level fields.
1614
+ */
1615
+ const PERSON_STRING_FIELDS = [
1616
+ "attachable_sgid",
1617
+ "avatar_url",
1618
+ "bio",
1619
+ "created_at",
1620
+ "email_address",
1621
+ "location",
1622
+ "name",
1623
+ "personable_type",
1624
+ "tagline",
1625
+ "time_zone",
1626
+ "title",
1627
+ "updated_at",
1628
+ ];
1629
+ const PERSON_BOOL_FIELDS = [
1630
+ "admin",
1631
+ "can_access_hill_charts",
1632
+ "can_access_timesheet",
1633
+ "can_manage_people",
1634
+ "can_manage_projects",
1635
+ "can_ping",
1636
+ "client",
1637
+ "employee",
1638
+ "owner",
1639
+ ];
1640
+ const PARENT_STRING_FIELDS = ["title", "type", "url", "app_url"];
1641
+ const BUCKET_STRING_FIELDS = ["name", "type"];
1642
+ /** Go's int64 window, the bound `FlexibleInt64` decodes within. */
1643
+ const INT64_MAX = 9223372036854775807n;
1644
+ const INT64_MIN = -9223372036854775808n;
1645
+ function memberString(member, field, what) {
1646
+ const value = member[field];
1647
+ // NULL IS NOT A WRONG TYPE. Every one of these is a `*string` or a plain
1648
+ // `string` in the model and `json.Unmarshal` of `null` is a no-op at any
1649
+ // depth, so only a PRESENT value of the wrong type fails.
1650
+ if (value === undefined || value === null || typeof value === "string")
1651
+ return;
1652
+ throw Errors.apiError(truncateErrorMessage(`${what} ${field} is ${describeWireValue(value)} rather than a string`), undefined, { retryable: false, hint: "the response is malformed; the recording cannot be summarized from it" });
1653
+ }
1654
+ function memberBool(member, field, what) {
1655
+ const value = member[field];
1656
+ // `1` does NOT pass. JavaScript would read it as truthy; Go refuses a number
1657
+ // for a bool outright, and this is the read side of a payload nobody here
1658
+ // wrote.
1659
+ if (value === undefined || value === null || typeof value === "boolean")
1660
+ return;
1661
+ throw Errors.apiError(truncateErrorMessage(`${what} ${field} is ${describeWireValue(value)} rather than a boolean`), undefined, { retryable: false, hint: "the response is malformed; the recording cannot be summarized from it" });
1662
+ }
1663
+ /**
1664
+ * A person's `company`: `PersonCompany{Id int64, Name string}`.
1665
+ *
1666
+ * Its id takes the PLAIN reader, not the flexible one the person's own id
1667
+ * takes — `{"company":{"id":"1"}}` fails the read in the reference where
1668
+ * `{"id":"1"}` on the person beside it resolves.
1669
+ */
1670
+ function memberCompany(member, what) {
1671
+ const company = member["company"];
1672
+ if (company === undefined || company === null)
1673
+ return;
1674
+ if (typeof company !== "object" || Array.isArray(company)) {
1675
+ throw Errors.apiError(truncateErrorMessage(`${what} company is ${describeWireValue(company)} rather than an object`), undefined, { retryable: false, hint: "the response is malformed; the recording cannot be summarized from it" });
1676
+ }
1677
+ const fields = company;
1678
+ wireInteger(fields["id"], `${what} company`, RECORDING_HINT);
1679
+ memberString(fields, "name", `${what} company`);
1680
+ }
1681
+ /**
1682
+ * `types.FlexibleInt64`, which is what a person's id — and only a person's id —
1683
+ * decodes through in the generated model.
1684
+ *
1685
+ * It differs from a plain `int64` in three directions that are easy to get
1686
+ * backwards, all measured against the reference rather than inferred:
1687
+ *
1688
+ * - a NON-NUMERIC string is 0, not an error. That 0 is the system-actor
1689
+ * sentinel ("basecamp", "campfire"), so reading it as a person id would
1690
+ * name a real person where Go names no one.
1691
+ * - `null` IS an error here, where a plain int64 field reads it as 0:
1692
+ * `FlexibleInt64.UnmarshalJSON` is called for null and decodes it as a
1693
+ * number.
1694
+ * - the int64 WINDOW still applies to both forms. A number is not exempt
1695
+ * from it because it arrived as a number: `1e20` and `9223372036854775808`
1696
+ * are `flexibleint64: not a valid int64` in the reference, and an earlier
1697
+ * spelling of this function bounded the string branch and left the number
1698
+ * branch on a bare `Number.isInteger` — the same clause `wireInteger` four
1699
+ * definitions above already carries, dropped in a copy of its own policy.
1700
+ *
1701
+ * It CONVERTS as well as validates, and the conversion is the half TypeScript
1702
+ * was missing: `"007"` is 7 in the summary Go hands back, so validating the
1703
+ * string and then emitting it left this port's projection a different shape
1704
+ * from the contract it shares — a `creator.id` of type `string` where the other
1705
+ * six SDKs emit a number.
1706
+ *
1707
+ * WHAT REACHES THIS READER, which is narrower than it looks and which two
1708
+ * rounds of review were needed to state correctly. `normalizePersonIds`
1709
+ * (`services/base.ts`) runs over every response body before any composite sees
1710
+ * it, and rewrites the `id` of any object carrying `personable_type` — a
1711
+ * numeric string in safe range becomes the number, and anything else becomes
1712
+ * `0` plus a manufactured `system_label`. Every real BC3 person carries
1713
+ * `personable_type`, so on a realistic body the conversion below has already
1714
+ * happened upstream. This reader governs the person objects that LACK it,
1715
+ * which is where this port used to emit `"007"` as a string.
1716
+ *
1717
+ * THREE RESIDUES, declared because this runtime cannot close them and because
1718
+ * two earlier versions of this comment called a shorter list exhaustive:
1719
+ *
1720
+ * - `7.0` is accepted as 7. `JSON.parse` cannot see the fraction, so nothing
1721
+ * at this layer can tell it from `7`, while Go's decoder reads the raw
1722
+ * bytes and refuses it. Ruby and Python both refuse it; this port cannot.
1723
+ * - `created_at` / `updated_at` are `*time.Time` in the model, so Go refuses
1724
+ * an unparseable string as well as a non-string. This port checks only the
1725
+ * TYPE, the same residue Ruby's `#read_member_fields` and its SPEC row
1726
+ * declare for the same two fields.
1727
+ * - an id past `Number.MAX_SAFE_INTEGER` is REFUSED here rather than rounded.
1728
+ * The reference accepts `"9223372036854775807"` and carries it exactly;
1729
+ * JavaScript cannot, and `Number(BigInt("9223372036854775807"))` is 2^63 —
1730
+ * a value this function's own overflow guard exists to reject, arrived at
1731
+ * one line after passing it. Refusing is the direction that does not hand a
1732
+ * caller a person id naming someone else.
1733
+ *
1734
+ * This is deliberately STRICTER than the sibling ids, and the inconsistency
1735
+ * is stated rather than smoothed over: `summary.id` and `parent.id` emit
1736
+ * the rounded value under retained waiver 1B.6, and an earlier version of
1737
+ * this paragraph claimed the refusal was "the rule `numericId` and
1738
+ * `summarize` already apply", which it is not — those two apply it to ids
1739
+ * that ADDRESS something and to ids the CALLER supplied. The argument for
1740
+ * the stricter rule here is only that a person id names a person; whether
1741
+ * 1B.6 should be revisited for the others is its own question and not
1742
+ * this change's to answer.
1743
+ *
1744
+ * Where the pre-pass above DID run, this branch is unreachable and the
1745
+ * caller has already been handed `0` and a `system_label` the reference
1746
+ * never emits. That is a divergence in `normalizePersonIds` rather than in
1747
+ * this composite, it is SDK-wide rather than local to the summary, and it
1748
+ * is recorded on card 39 for the person-id card rather than patched here.
1749
+ */
1750
+ function flexiblePersonId(value, what) {
1751
+ if (typeof value === "string") {
1752
+ // Go's `strconv.ParseInt` takes a sign and rejects everything else whole;
1753
+ // there is no prefix parsing and no whitespace trimming.
1754
+ if (!/^[+-]?\d+$/.test(value))
1755
+ return 0;
1756
+ return boundedPersonId(BigInt(value), value, what);
1757
+ }
1758
+ if (typeof value !== "number" || !Number.isInteger(value)) {
1759
+ throw Errors.apiError(truncateErrorMessage(`${what} id is ${describeWireValue(value)} rather than a person id`), undefined, { retryable: false, hint: RECORDING_HINT });
1760
+ }
1761
+ // A number reaching here has already been through `JSON.parse`, so anything
1762
+ // past 2^53 was rounded before this port could see it and the exactness this
1763
+ // guard asks for was lost upstream. The WINDOW is still checkable and still
1764
+ // the reference's, which is what this enforces.
1765
+ return boundedPersonId(BigInt(value), value, what);
1766
+ }
1767
+ /** The int64 window and this runtime's exactness limit, for a person id. */
1768
+ function boundedPersonId(parsed, raw, what) {
1769
+ if (parsed > INT64_MAX || parsed < INT64_MIN) {
1770
+ throw Errors.apiError(truncateErrorMessage(`${what} id overflows int64 (${describeIdValue(raw)})`), undefined, { retryable: false, hint: RECORDING_HINT });
1771
+ }
1772
+ const id = Number(parsed);
1773
+ if (!Number.isSafeInteger(id)) {
1774
+ throw Errors.apiError(truncateErrorMessage(`${what} id cannot be represented exactly by this runtime (${describeIdValue(raw)})`), undefined, { retryable: false, hint: RECORDING_HINT });
1775
+ }
1776
+ return id;
1777
+ }
1778
+ /**
1779
+ * A nested identity, typed as the reference types it, or `undefined` for Go's
1780
+ * zero value.
1781
+ *
1782
+ * This used to check only that the slot held an OBJECT and then carry the
1783
+ * member verbatim, under a doc comment claiming "a shape Go's decoder refuses
1784
+ * fails the read here too" — which was true of the container and false of every
1785
+ * field inside it, so `{"creator":{"name":7}}` produced a summary whose `name`
1786
+ * was a number in a field the contract declares a string. Card 39 settled the
1787
+ * cross-port fork in favour of the reference's line: four of the seven SDKs
1788
+ * reproduce the whole nested decode for free through a typed decoder and cannot
1789
+ * stop short of it, so a port that stopped at the container was the outlier.
1790
+ *
1791
+ * "Typed as the reference types it" is meant literally and is held to it by
1792
+ * `conformance/tests/recording_summary.json`. What falls short is enumerated in
1793
+ * full in SPEC's TypeScript row rather than here, because every partial copy of
1794
+ * that list in this file has drifted from it. Of the eight named there, SEVEN
1795
+ * pass through this function — all but `normalizePersonIds`, which rewrites the
1796
+ * body before any composite sees it:
1797
+ *
1798
+ * - the three `flexiblePersonId` names: a `7.0` this runtime cannot see, a
1799
+ * person's `created_at` / `updated_at` checked for type and not parsed, and
1800
+ * an id past 2^53 it cannot hold;
1801
+ * - `9223372036854775807` as a parent or bucket id, which `wireInteger` in the
1802
+ * two arms below refuses for the same parse reason;
1803
+ * - an assignee's null `name`, which the person arm passes through where Go
1804
+ * emits `""` (see `withEmptyLabel`);
1805
+ * - the EMPTINESS rule, on a different axis. Go builds a nested member only
1806
+ * when `Id != 0 || Name != ""`, so an all-zero member leaves the key out
1807
+ * there and Ruby drops it too, while this port emits `{}`. That is the
1808
+ * projection's shape rather than its typing, and it predates this rule;
1809
+ * - and the parent's nested bucket, below.
1810
+ *
1811
+ * The first version of this comment claimed a completeness it did not have,
1812
+ * which is the failure mode this whole card exists to correct.
1813
+ *
1814
+ * The parent's nested bucket, on the EMISSION side and shared with Ruby: a parent's nested `bucket` is
1815
+ * typed here — a wrong-typed one fails the read, as the generated decode does —
1816
+ * and then passed through whole with its id undecoded, while the reference's
1817
+ * conversion builds a `Parent` without a bucket and emits no such key at all.
1818
+ * The recursive call below is for its refusals only, and says so.
1819
+ */
1820
+ function nestedIdentity(value, what, kind, parentHasBucket = true) {
1821
+ if (value === undefined || value === null)
1822
+ return undefined;
1823
+ if (typeof value !== "object" || Array.isArray(value)) {
1824
+ throw Errors.apiError(truncateErrorMessage(`${what} is ${describeWireValue(value)} rather than an object`), undefined, { retryable: false, hint: "the response is malformed; the recording cannot be summarized from it" });
1825
+ }
1826
+ const member = value;
1827
+ switch (kind) {
1828
+ case "person": {
1829
+ for (const field of PERSON_STRING_FIELDS)
1830
+ memberString(member, field, what);
1831
+ for (const field of PERSON_BOOL_FIELDS)
1832
+ memberBool(member, field, what);
1833
+ memberCompany(member, what);
1834
+ // Absent is the zero value and is NOT invented — the reference emits a
1835
+ // whole struct and this tier emits the object it was given, so writing an
1836
+ // id in would be half a decode in the other direction.
1837
+ if (!("id" in member))
1838
+ return value;
1839
+ const id = flexiblePersonId(member["id"], what);
1840
+ if (id === member["id"])
1841
+ return value;
1842
+ // Copied rather than mutated: the caller's response object is not ours.
1843
+ return { ...member, id };
1844
+ }
1845
+ case "parent": {
1846
+ for (const field of PARENT_STRING_FIELDS)
1847
+ memberString(member, field, what);
1848
+ // A parent's `Id` is a PLAIN `int64` — not the `FlexibleInt64` a person's
1849
+ // is, so `"7"` fails the read here and resolves one member over. This was
1850
+ // missing while the doc above claimed the member was typed as the
1851
+ // reference types it, which is the one kind of comment this file's own
1852
+ // history says to distrust.
1853
+ const parentId = wireInteger(member["id"], what, RECORDING_HINT);
1854
+ // A parent's bucket is a typed `*RecordingBucket` under
1855
+ // `RecordingParent` and does not exist AT ALL under `TodoParent`, which
1856
+ // is what a todo and a todolist carry. The same key is a typed struct
1857
+ // under one containing type and an ignored unknown under another, and
1858
+ // only the reference says which — hence the flag, threaded from the
1859
+ // routing arm rather than guessed from the payload. One shape for both
1860
+ // would refuse `{"parent":{"bucket":7}}` on a todo, a body the reference
1861
+ // ACCEPTS, which is the direction that matters.
1862
+ // For its REFUSALS only — the return value is discarded, so the nested
1863
+ // bucket is emitted as it arrived. See the emission residue above.
1864
+ if (parentHasBucket)
1865
+ nestedIdentity(member["bucket"], `${what} bucket`, "bucket");
1866
+ return withDecodedId(member, parentId, value);
1867
+ }
1868
+ case "bucket": {
1869
+ for (const field of BUCKET_STRING_FIELDS)
1870
+ memberString(member, field, what);
1871
+ // `RecordingBucket` and `TodoBucket` are `{id int64, name string, type
1872
+ // string}` — THREE fields, and this arm walked two of them. The
1873
+ // top-level bucket's id is read again by the cross-project check below,
1874
+ // which is why the gap was invisible there; a PARENT's bucket has no such
1875
+ // second reader, so `{"parent":{"bucket":{"id":"7"}}}` projected a
1876
+ // summary where the reference fails the read.
1877
+ return withDecodedId(member, wireInteger(member["id"], what, RECORDING_HINT), value);
1878
+ }
1879
+ }
1880
+ }
1881
+ /**
1882
+ * The member with its DECODED id, for the two members whose id is a plain
1883
+ * `int64`.
1884
+ *
1885
+ * Reading an id one way and emitting it another is the defect this file has
1886
+ * already fixed twice, one member over: a `null` id is the zero value in Go and
1887
+ * reaches a caller as `0` there and in Ruby, while passing the member through
1888
+ * verbatim left it as `null` — a shape no other SDK produces, in a field typed
1889
+ * `number`. An ABSENT id is still not invented; only a present one is written
1890
+ * back.
1891
+ */
1892
+ function withDecodedId(member, id, value) {
1893
+ if (!("id" in member) || member["id"] === id)
1894
+ return value;
1895
+ return { ...member, id };
1896
+ }
1897
+ /**
1898
+ * The member with a present-but-null LABEL written back as `""`.
1899
+ *
1900
+ * The label is the one string field the reference's emptiness rule reads —
1901
+ * `Name` for a bucket and a creator, `Title` for a parent — and all three are
1902
+ * `string` in the generated model, so a JSON null is the zero value and Go
1903
+ * emits `""`. Ruby writes it back the same way (`#read_member`). This port
1904
+ * passed the member through and emitted `null` in a field typed `string`, the
1905
+ * same read-one-way-emit-another shape `withDecodedId` closes for the id.
1906
+ *
1907
+ * Only these three members, and NOT an assignee — which is a divergence, stated
1908
+ * rather than hidden: Go decodes `[]Person` and emits a null assignee name as
1909
+ * `""` too, while this port and Ruby's `#read_assignees` both pass it through
1910
+ * as null. It is left matching Ruby rather than moved alone, because a port
1911
+ * fixing one member of a shared residue in isolation is how the ports came to
1912
+ * disagree in the first place. An ABSENT label is not invented.
1913
+ */
1914
+ function withEmptyLabel(value, label) {
1915
+ if (value === undefined)
1916
+ return undefined;
1917
+ const member = value;
1918
+ if (!(label in member) || member[label] !== null)
1919
+ return value;
1920
+ return { ...member, [label]: "" };
1921
+ }
1922
+ /**
1923
+ * A string field a routed read carries, refused when it is not one.
1924
+ *
1925
+ * Go's generated models decode these as `string`: a JSON null is the zero value
1926
+ * and no error, and a number, a boolean, an object or an array fails the read.
1927
+ * TypeScript carried whatever arrived into the projection, so `content: 42`
1928
+ * reached `mentionedPersonIds`, whose first string operation threw a raw
1929
+ * TypeError out of `summarize` — the same untyped-throw boundary the body and
1930
+ * container checks close, one level further in.
1931
+ */
1932
+ function recordingText(value, what, hint = RECORDING_HINT) {
1933
+ if (value === undefined || value === null)
1934
+ return "";
1935
+ if (typeof value !== "string") {
1936
+ throw Errors.apiError(truncateErrorMessage(`${what} is ${describeWireValue(value)} rather than text`), undefined, { retryable: false, hint });
1937
+ }
1938
+ return value;
1939
+ }
1940
+ //# sourceMappingURL=recordings-extensions.js.map