@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.
- package/README.md +94 -2
- package/dist/client.d.ts +6 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +11 -5
- package/dist/client.js.map +1 -1
- package/dist/download.js +1 -1
- package/dist/errors.d.ts +10 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +42 -13
- package/dist/errors.js.map +1 -1
- package/dist/generated/metadata.d.ts +1 -2
- package/dist/generated/metadata.d.ts.map +1 -1
- package/dist/generated/metadata.js +47 -1
- package/dist/generated/metadata.js.map +1 -1
- package/dist/generated/metadata.ts +48 -3
- package/dist/generated/openapi-stripped.json +772 -16
- package/dist/generated/path-mapping.d.ts.map +1 -1
- package/dist/generated/path-mapping.js +7 -4
- package/dist/generated/path-mapping.js.map +1 -1
- package/dist/generated/path-mapping.ts +8 -5
- package/dist/generated/person-id-sites.d.ts +2 -0
- package/dist/generated/person-id-sites.d.ts.map +1 -0
- package/dist/generated/person-id-sites.js +689 -0
- package/dist/generated/person-id-sites.js.map +1 -0
- package/dist/generated/person-id-sites.ts +690 -0
- package/dist/generated/schema.d.ts +643 -12
- package/dist/generated/services/event-feed.d.ts +92 -0
- package/dist/generated/services/event-feed.d.ts.map +1 -0
- package/dist/generated/services/event-feed.js +80 -0
- package/dist/generated/services/event-feed.js.map +1 -0
- package/dist/generated/services/event-feed.ts +153 -0
- package/dist/generated/services/gauges.d.ts +7 -3
- package/dist/generated/services/gauges.d.ts.map +1 -1
- package/dist/generated/services/gauges.js +4 -1
- package/dist/generated/services/gauges.js.map +1 -1
- package/dist/generated/services/gauges.ts +10 -3
- package/dist/generated/services/index.d.ts +1 -0
- package/dist/generated/services/index.d.ts.map +1 -1
- package/dist/generated/services/index.js +1 -0
- package/dist/generated/services/index.js.map +1 -1
- package/dist/generated/services/index.ts +1 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -3
- package/dist/index.js.map +1 -1
- package/dist/person-id.d.ts +133 -0
- package/dist/person-id.d.ts.map +1 -0
- package/dist/person-id.js +163 -0
- package/dist/person-id.js.map +1 -0
- package/dist/retry.d.ts +2 -1
- package/dist/retry.d.ts.map +1 -1
- package/dist/retry.js +21 -11
- package/dist/retry.js.map +1 -1
- package/dist/services/base.d.ts +35 -0
- package/dist/services/base.d.ts.map +1 -1
- package/dist/services/base.js +387 -55
- package/dist/services/base.js.map +1 -1
- package/dist/services/comments-extensions.d.ts +89 -0
- package/dist/services/comments-extensions.d.ts.map +1 -0
- package/dist/services/comments-extensions.js +166 -0
- package/dist/services/comments-extensions.js.map +1 -0
- package/dist/services/mentions.d.ts +255 -0
- package/dist/services/mentions.d.ts.map +1 -0
- package/dist/services/mentions.js +1431 -0
- package/dist/services/mentions.js.map +1 -0
- package/dist/services/merge-safe.d.ts +13 -5
- package/dist/services/merge-safe.d.ts.map +1 -1
- package/dist/services/merge-safe.js +71 -13
- package/dist/services/merge-safe.js.map +1 -1
- package/dist/services/recordings-extensions.d.ts +354 -0
- package/dist/services/recordings-extensions.d.ts.map +1 -0
- package/dist/services/recordings-extensions.js +1940 -0
- package/dist/services/recordings-extensions.js.map +1 -0
- package/package.json +4 -4
- package/src/generated/metadata.ts +48 -3
- package/src/generated/openapi-stripped.json +772 -16
- package/src/generated/path-mapping.ts +8 -5
- package/src/generated/person-id-sites.ts +690 -0
- package/src/generated/schema.d.ts +643 -12
- package/src/generated/services/event-feed.ts +153 -0
- package/src/generated/services/gauges.ts +10 -3
- package/src/generated/services/index.ts +1 -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
|