@haverstack/core 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +19 -15
  2. package/dist/access.d.ts +4 -6
  3. package/dist/access.d.ts.map +1 -1
  4. package/dist/access.js +7 -8
  5. package/dist/access.js.map +1 -1
  6. package/dist/adapter-entry.d.ts +7 -6
  7. package/dist/adapter-entry.d.ts.map +1 -1
  8. package/dist/adapter-entry.js +6 -5
  9. package/dist/adapter-entry.js.map +1 -1
  10. package/dist/attachment-download.d.ts +3 -1
  11. package/dist/attachment-download.d.ts.map +1 -1
  12. package/dist/attachment-download.js +1 -1
  13. package/dist/attachment-download.js.map +1 -1
  14. package/dist/changes.d.ts +22 -16
  15. package/dist/changes.d.ts.map +1 -1
  16. package/dist/changes.js +43 -32
  17. package/dist/changes.js.map +1 -1
  18. package/dist/combine.d.ts +1 -1
  19. package/dist/combine.js +6 -6
  20. package/dist/combine.js.map +1 -1
  21. package/dist/errors.d.ts +24 -12
  22. package/dist/errors.d.ts.map +1 -1
  23. package/dist/errors.js +32 -17
  24. package/dist/errors.js.map +1 -1
  25. package/dist/grants.d.ts +17 -25
  26. package/dist/grants.d.ts.map +1 -1
  27. package/dist/grants.js +27 -14
  28. package/dist/grants.js.map +1 -1
  29. package/dist/identity-bindings.js +2 -2
  30. package/dist/index.d.ts +12 -8
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +12 -6
  33. package/dist/index.js.map +1 -1
  34. package/dist/query-validation.d.ts +28 -11
  35. package/dist/query-validation.d.ts.map +1 -1
  36. package/dist/query-validation.js +95 -36
  37. package/dist/query-validation.js.map +1 -1
  38. package/dist/record-changes.d.ts +11 -10
  39. package/dist/record-changes.d.ts.map +1 -1
  40. package/dist/record-changes.js +33 -25
  41. package/dist/record-changes.js.map +1 -1
  42. package/dist/record-id.d.ts +3 -3
  43. package/dist/record-id.js +8 -8
  44. package/dist/record-id.js.map +1 -1
  45. package/dist/scoped-stack.d.ts +71 -34
  46. package/dist/scoped-stack.d.ts.map +1 -1
  47. package/dist/scoped-stack.js +107 -88
  48. package/dist/scoped-stack.js.map +1 -1
  49. package/dist/stack-reads.d.ts +2 -2
  50. package/dist/stack-reads.js +5 -5
  51. package/dist/stack-reads.js.map +1 -1
  52. package/dist/stack.d.ts +132 -161
  53. package/dist/stack.d.ts.map +1 -1
  54. package/dist/stack.js +287 -288
  55. package/dist/stack.js.map +1 -1
  56. package/dist/testing.d.ts +17 -17
  57. package/dist/testing.d.ts.map +1 -1
  58. package/dist/testing.js +46 -42
  59. package/dist/testing.js.map +1 -1
  60. package/dist/types.d.ts +169 -143
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/types.js +2 -2
  63. package/dist/types.js.map +1 -1
  64. package/dist/wire-body.d.ts +71 -0
  65. package/dist/wire-body.d.ts.map +1 -0
  66. package/dist/wire-body.js +138 -0
  67. package/dist/wire-body.js.map +1 -0
  68. package/dist/wire-entry.d.ts +9 -8
  69. package/dist/wire-entry.d.ts.map +1 -1
  70. package/dist/wire-entry.js +13 -14
  71. package/dist/wire-entry.js.map +1 -1
  72. package/dist/wire-record.d.ts +4 -4
  73. package/dist/wire-record.d.ts.map +1 -1
  74. package/dist/wire-record.js +35 -22
  75. package/dist/wire-record.js.map +1 -1
  76. package/dist/wire-request.d.ts +37 -4
  77. package/dist/wire-request.d.ts.map +1 -1
  78. package/dist/wire-request.js +288 -79
  79. package/dist/wire-request.js.map +1 -1
  80. package/package.json +1 -1
@@ -20,11 +20,12 @@
20
20
  * `Stack`'s to judge, and judging it twice would let the two answers
21
21
  * drift.
22
22
  *
23
- * Everything here throws `StackQueryError` on malformed input, which
23
+ * Everything here throws `StackBadRequestError` on malformed input, which
24
24
  * servers already map to 400 — see docs/spec/wire-format.md § Error
25
25
  * responses.
26
26
  */
27
- import { StackQueryError } from './errors.js';
27
+ import { StackBadRequestError } from './errors.js';
28
+ import { TARGET_KEYS } from './query-validation.js';
28
29
  import { NATIVE_SORT_FIELDS } from './types.js';
29
30
  // -------------------------------------------------------
30
31
  // Shared primitives
@@ -40,7 +41,70 @@ const POSITIVE_INTEGER = /^\d+$/;
40
41
  const SORT_FIELDS = new Set(NATIVE_SORT_FIELDS);
41
42
  const SORT_DIRECTIONS = new Set(['asc', 'desc']);
42
43
  const CHANGE_KINDS = new Set(['created', 'changed', 'deleted', 'purged']);
43
- const TARGET_SCOPES = new Set(['record', 'entity', 'external']);
44
+ const ASSOCIATION_KINDS = new Set(['tag', 'attachment', 'relationship']);
45
+ const TARGET_KINDS = new Set(['record', 'entity', 'external']);
46
+ /** Every param `GET /records` defines. See docs/spec/wire-format.md § Records. */
47
+ const RECORD_QUERY_PARAMS = [
48
+ 'typeId',
49
+ 'parentId',
50
+ 'appId',
51
+ 'createdBySubject',
52
+ 'createdByPrincipal',
53
+ 'tag',
54
+ 'attachmentLabel',
55
+ 'attachmentFileId',
56
+ 'referencesFileId',
57
+ 'relatedTo',
58
+ 'relatedToStack',
59
+ 'relatedToEntity',
60
+ 'relatedToNs',
61
+ 'relatedToId',
62
+ 'relatedToLabel',
63
+ 'search',
64
+ 'createdBefore',
65
+ 'createdAfter',
66
+ 'updatedBefore',
67
+ 'updatedAfter',
68
+ 'includeDeleted',
69
+ 'includeUnlisted',
70
+ 'sort',
71
+ 'sortContent',
72
+ 'direction',
73
+ 'limit',
74
+ 'cursor',
75
+ ];
76
+ const QUERY_BODY_FILTER_KEYS = [
77
+ 'typeId',
78
+ 'parentId',
79
+ 'appId',
80
+ 'createdBy',
81
+ 'tags',
82
+ 'attachment',
83
+ 'referencesFileId',
84
+ 'relatedTo',
85
+ 'content',
86
+ 'contentPresent',
87
+ 'search',
88
+ 'includeDeleted',
89
+ 'includeUnlisted',
90
+ 'createdAt',
91
+ 'updatedAt',
92
+ ];
93
+ /**
94
+ * Every param `GET /changes` defines. `since` is the resume cursor, which
95
+ * the server reads itself — see parseChangeParams().
96
+ */
97
+ const CHANGE_PARAMS = [
98
+ 'typeId',
99
+ 'baseId',
100
+ 'parentId',
101
+ 'createdBySubject',
102
+ 'createdByPrincipal',
103
+ 'kind',
104
+ 'include',
105
+ 'includeUnlisted',
106
+ 'since',
107
+ ];
44
108
  /**
45
109
  * Strict positive-integer parse for a URL param — rejects "1abc", "2.7",
46
110
  * "-5". Also serves the `:version` path params, which share this
@@ -48,18 +112,18 @@ const TARGET_SCOPES = new Set(['record', 'entity', 'external']);
48
112
  */
49
113
  export function parsePositiveInt(raw, label) {
50
114
  if (!POSITIVE_INTEGER.test(raw))
51
- throw new StackQueryError(`Invalid ${label}: "${raw}"`);
115
+ throw new StackBadRequestError(`Invalid ${label}: "${raw}"`);
52
116
  return parseInt(raw, 10);
53
117
  }
54
118
  function requireDate(raw, label) {
55
119
  const d = parseDate(raw);
56
120
  if (!d)
57
- throw new StackQueryError(`Invalid ${label}: ${JSON.stringify(raw)}`);
121
+ throw new StackBadRequestError(`Invalid ${label}: ${JSON.stringify(raw)}`);
58
122
  return d;
59
123
  }
60
124
  function requireSortField(raw) {
61
125
  if (typeof raw !== 'string' || !SORT_FIELDS.has(raw))
62
- throw new StackQueryError(`Invalid sort field: ${JSON.stringify(raw)}`);
126
+ throw new StackBadRequestError(`Invalid sort field: ${JSON.stringify(raw)}`);
63
127
  return raw;
64
128
  }
65
129
  /**
@@ -70,7 +134,7 @@ function requireSortField(raw) {
70
134
  */
71
135
  function buildSort(field, contentField, direction) {
72
136
  if (field !== undefined && field !== null && contentField !== undefined && contentField !== null)
73
- throw new StackQueryError('A sort names either a native field or a content field, never both.');
137
+ throw new StackBadRequestError('A sort names either a native field or a content field, never both.');
74
138
  const dir = direction === undefined || direction === null ? undefined : requireSortDirection(direction);
75
139
  if (contentField !== undefined && contentField !== null) {
76
140
  return {
@@ -85,12 +149,12 @@ function buildSort(field, contentField, direction) {
85
149
  }
86
150
  function requireSortDirection(raw) {
87
151
  if (typeof raw !== 'string' || !SORT_DIRECTIONS.has(raw))
88
- throw new StackQueryError(`Invalid sort direction: ${JSON.stringify(raw)}`);
152
+ throw new StackBadRequestError(`Invalid sort direction: ${JSON.stringify(raw)}`);
89
153
  return raw;
90
154
  }
91
155
  function requireString(raw, label) {
92
156
  if (typeof raw !== 'string')
93
- throw new StackQueryError(`Invalid ${label}: expected a string`);
157
+ throw new StackBadRequestError(`Invalid ${label}: expected a string`);
94
158
  return raw;
95
159
  }
96
160
  function requireStringOrArray(raw, label) {
@@ -98,16 +162,67 @@ function requireStringOrArray(raw, label) {
98
162
  return raw;
99
163
  if (Array.isArray(raw) && raw.every((v) => typeof v === 'string'))
100
164
  return raw;
101
- throw new StackQueryError(`Invalid ${label}: expected a string or array of strings`);
165
+ throw new StackBadRequestError(`Invalid ${label}: expected a string or array of strings`);
102
166
  }
103
167
  function requireStringArray(raw, label) {
104
168
  if (!Array.isArray(raw) || !raw.every((v) => typeof v === 'string'))
105
- throw new StackQueryError(`Invalid ${label}: expected an array of strings`);
169
+ throw new StackBadRequestError(`Invalid ${label}: expected an array of strings`);
106
170
  return raw;
107
171
  }
108
172
  function requirePlainObject(raw, label) {
109
173
  if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
110
- throw new StackQueryError(`Invalid ${label}: expected an object`);
174
+ throw new StackBadRequestError(`Invalid ${label}: expected an object`);
175
+ return raw;
176
+ }
177
+ /**
178
+ * A plain object carrying only `keys`. An unrecognized key is refused
179
+ * rather than ignored, since ignoring it answers a different request than
180
+ * the one sent. See docs/spec/wire-format.md § Unrecognized input.
181
+ */
182
+ function requireKnownKeys(raw, keys, label) {
183
+ const obj = requirePlainObject(raw, label);
184
+ const unknown = Object.keys(obj).filter((key) => !keys.includes(key));
185
+ if (unknown.length > 0)
186
+ throw new StackBadRequestError(`Unknown key${unknown.length > 1 ? 's' : ''} in ${label}: ${unknown.join(', ')}`);
187
+ return obj;
188
+ }
189
+ /**
190
+ * Params a filter repeats to name several values. Any other param names
191
+ * one value, so a repeat of it is refused rather than read as its first.
192
+ */
193
+ const REPEATABLE_PARAMS = new Set([
194
+ 'typeId',
195
+ 'baseId',
196
+ 'appId',
197
+ 'createdBySubject',
198
+ 'createdByPrincipal',
199
+ 'tag',
200
+ 'kind',
201
+ ]);
202
+ /** The URL-param form of requireKnownKeys(), which also refuses a repeat. */
203
+ function requireKnownParams(url, names) {
204
+ const present = [...new Set(url.searchParams.keys())];
205
+ const unknown = present.filter((name) => !names.includes(name));
206
+ if (unknown.length > 0)
207
+ throw new StackBadRequestError(`Unknown query param${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`);
208
+ const repeated = present.filter((name) => !REPEATABLE_PARAMS.has(name) && url.searchParams.getAll(name).length > 1);
209
+ if (repeated.length > 0)
210
+ throw new StackBadRequestError(`Repeated query param${repeated.length > 1 ? 's' : ''}: ${repeated.join(', ')}`);
211
+ }
212
+ /** A boolean URL param: absent is false, and only `true`/`false` are values. */
213
+ function booleanParam(url, name) {
214
+ const value = url.searchParams.get(name);
215
+ if (value === null || value === 'false')
216
+ return false;
217
+ if (value === 'true')
218
+ return true;
219
+ throw new StackBadRequestError(`Invalid ${name}: expected true or false, got "${value}"`);
220
+ }
221
+ function optionalBoolean(raw, label) {
222
+ if (raw === undefined)
223
+ return undefined;
224
+ if (typeof raw !== 'boolean')
225
+ throw new StackBadRequestError(`Invalid ${label}: expected a boolean`);
111
226
  return raw;
112
227
  }
113
228
  // -------------------------------------------------------
@@ -127,9 +242,9 @@ const PRESENT_AT_REFUSAL = 'presentAt applies migration functions, which are app
127
242
  */
128
243
  function assertNoUntravelableFields(hasBaseId, hasPresentAt) {
129
244
  if (hasBaseId)
130
- throw new StackQueryError(BASE_ID_REFUSAL);
245
+ throw new StackBadRequestError(BASE_ID_REFUSAL);
131
246
  if (hasPresentAt)
132
- throw new StackQueryError(PRESENT_AT_REFUSAL);
247
+ throw new StackBadRequestError(PRESENT_AT_REFUSAL);
133
248
  }
134
249
  /**
135
250
  * The client half of the same refusal, applied before a request is built
@@ -151,38 +266,40 @@ export function assertQueryTravels(query) {
151
266
  // Relationship filter
152
267
  // -------------------------------------------------------
153
268
  /**
154
- * Route a `filter.relatedTo.target` by scope. Core validates the
155
- * non-emptiness of whichever fields the scope names, so this only rejects
156
- * a scope it does not recognize.
269
+ * Route a `filter.relatedTo.target` by kind. Core validates the
270
+ * non-emptiness of whichever fields the kind names, so this only rejects
271
+ * a kind it does not recognize.
157
272
  */
158
273
  function parseRelatedToTarget(raw) {
159
- const t = requirePlainObject(raw, 'filter.relatedTo.target');
160
- if (typeof t.scope !== 'string' || !TARGET_SCOPES.has(t.scope))
161
- throw new StackQueryError(`Invalid filter.relatedTo.target.scope: ${JSON.stringify(t.scope)}`);
162
- if (t.scope === 'record') {
274
+ const label = 'filter.relatedTo.target';
275
+ const kind = requirePlainObject(raw, label).kind;
276
+ if (typeof kind !== 'string' || !TARGET_KINDS.has(kind))
277
+ throw new StackBadRequestError(`Invalid filter.relatedTo.target.kind: ${JSON.stringify(kind)}`);
278
+ const t = requireKnownKeys(raw, TARGET_KEYS[kind], label);
279
+ if (t.kind === 'record') {
163
280
  return {
164
- scope: 'record',
281
+ kind: 'record',
165
282
  recordId: requireString(t.recordId, 'filter.relatedTo.target.recordId'),
166
283
  ...(t.stackUrl !== undefined && {
167
284
  stackUrl: requireString(t.stackUrl, 'filter.relatedTo.target.stackUrl'),
168
285
  }),
169
286
  };
170
287
  }
171
- if (t.scope === 'entity') {
288
+ if (t.kind === 'entity') {
172
289
  return {
173
- scope: 'entity',
290
+ kind: 'entity',
174
291
  entityId: requireString(t.entityId, 'filter.relatedTo.target.entityId'),
175
292
  };
176
293
  }
177
294
  return {
178
- scope: 'external',
295
+ kind: 'external',
179
296
  ns: requireString(t.ns, 'filter.relatedTo.target.ns'),
180
297
  ...(t.id !== undefined && { id: requireString(t.id, 'filter.relatedTo.target.id') }),
181
298
  };
182
299
  }
183
300
  /** A label, a target, or both — the body form of the same filter. */
184
301
  function parseRelatedToBody(raw) {
185
- const r = requirePlainObject(raw, 'filter.relatedTo');
302
+ const r = requireKnownKeys(raw, ['label', 'target'], 'filter.relatedTo');
186
303
  const label = r.label !== undefined ? requireString(r.label, 'filter.relatedTo.label') : undefined;
187
304
  const target = r.target !== undefined ? parseRelatedToTarget(r.target) : undefined;
188
305
  return {
@@ -206,26 +323,26 @@ function parseRelatedToParams(url) {
206
323
  const hasId = url.searchParams.has('relatedToId');
207
324
  const label = url.searchParams.get('relatedToLabel');
208
325
  if ([hasRecord, hasEntity, hasNs].filter(Boolean).length > 1) {
209
- throw new StackQueryError('relatedTo, relatedToEntity and relatedToNs name different target scopes and are mutually exclusive');
326
+ throw new StackBadRequestError('relatedTo, relatedToEntity and relatedToNs name different target kinds and are mutually exclusive');
210
327
  }
211
328
  if (hasStack && !hasRecord)
212
- throw new StackQueryError('relatedToStack is only valid alongside relatedTo');
329
+ throw new StackBadRequestError('relatedToStack is only valid alongside relatedTo');
213
330
  if (hasId && !hasNs)
214
- throw new StackQueryError('relatedToId is only valid alongside relatedToNs');
331
+ throw new StackBadRequestError('relatedToId is only valid alongside relatedToNs');
215
332
  let target;
216
333
  if (hasRecord) {
217
334
  target = {
218
- scope: 'record',
335
+ kind: 'record',
219
336
  recordId: url.searchParams.get('relatedTo'),
220
337
  ...(hasStack && { stackUrl: url.searchParams.get('relatedToStack') }),
221
338
  };
222
339
  }
223
340
  else if (hasEntity) {
224
- target = { scope: 'entity', entityId: url.searchParams.get('relatedToEntity') };
341
+ target = { kind: 'entity', entityId: url.searchParams.get('relatedToEntity') };
225
342
  }
226
343
  else if (hasNs) {
227
344
  target = {
228
- scope: 'external',
345
+ kind: 'external',
229
346
  ns: url.searchParams.get('relatedToNs'),
230
347
  ...(hasId && { id: url.searchParams.get('relatedToId') }),
231
348
  };
@@ -237,9 +354,44 @@ function parseRelatedToParams(url) {
237
354
  ...(label && { label }),
238
355
  };
239
356
  }
357
+ /**
358
+ * The `attachment*` URL params; both halves together match one association.
359
+ * An empty value passes through rather than reading as absent, so it
360
+ * narrows to nothing instead of silently widening the query.
361
+ */
362
+ function parseAttachmentParams(url) {
363
+ const label = url.searchParams.get('attachmentLabel');
364
+ const fileId = url.searchParams.get('attachmentFileId');
365
+ if (label === null && fileId === null)
366
+ return undefined;
367
+ return {
368
+ ...(label !== null && { label }),
369
+ ...(fileId !== null && { fileId }),
370
+ };
371
+ }
372
+ function parseAttachmentBody(raw) {
373
+ const a = requireKnownKeys(raw, ['label', 'fileId'], 'filter.attachment');
374
+ return {
375
+ ...(a.label !== undefined && { label: requireString(a.label, 'filter.attachment.label') }),
376
+ ...(a.fileId !== undefined && { fileId: requireString(a.fileId, 'filter.attachment.fileId') }),
377
+ };
378
+ }
240
379
  // -------------------------------------------------------
241
380
  // GET /records
242
381
  // -------------------------------------------------------
382
+ /** The author filter, spelled the same on `GET /records` and `GET /changes`. */
383
+ function parseCreatedByParams(url) {
384
+ const subjectIds = url.searchParams.getAll('createdBySubject');
385
+ const principalIds = url.searchParams.getAll('createdByPrincipal');
386
+ if (!subjectIds.length && !principalIds.length)
387
+ return undefined;
388
+ return {
389
+ ...(subjectIds.length && { subjectId: subjectIds.length === 1 ? subjectIds[0] : subjectIds }),
390
+ ...(principalIds.length && {
391
+ principalId: principalIds.length === 1 ? principalIds[0] : principalIds,
392
+ }),
393
+ };
394
+ }
243
395
  /**
244
396
  * Build a `StackQuery` from `GET /records` search params — the inverse of
245
397
  * what `APIAdapter` encodes for a server that reaches no content.
@@ -249,6 +401,7 @@ function parseRelatedToParams(url) {
249
401
  */
250
402
  export function parseQueryParams(url) {
251
403
  assertNoUntravelableFields(url.searchParams.has('baseId'), url.searchParams.has('presentAt'));
404
+ requireKnownParams(url, RECORD_QUERY_PARAMS);
252
405
  const filter = {};
253
406
  const typeIds = url.searchParams.getAll('typeId');
254
407
  if (typeIds.length)
@@ -259,21 +412,18 @@ export function parseQueryParams(url) {
259
412
  const appIds = url.searchParams.getAll('appId');
260
413
  if (appIds.length)
261
414
  filter.appId = appIds.length === 1 ? appIds[0] : appIds;
262
- const entityIds = url.searchParams.getAll('entityId');
263
- if (entityIds.length)
264
- filter.entityId = entityIds.length === 1 ? entityIds[0] : entityIds;
265
- const principalIds = url.searchParams.getAll('principalId');
266
- if (principalIds.length)
267
- filter.principalId = principalIds.length === 1 ? principalIds[0] : principalIds;
415
+ const createdBy = parseCreatedByParams(url);
416
+ if (createdBy)
417
+ filter.createdBy = createdBy;
268
418
  const tags = url.searchParams.getAll('tag');
269
419
  if (tags.length)
270
420
  filter.tags = tags;
271
- const hasAttachment = url.searchParams.get('hasAttachment');
272
- if (hasAttachment)
273
- filter.hasAttachment = hasAttachment;
274
- const attachmentFileId = url.searchParams.get('attachmentFileId');
275
- if (attachmentFileId)
276
- filter.attachmentFileId = attachmentFileId;
421
+ const attachment = parseAttachmentParams(url);
422
+ if (attachment)
423
+ filter.attachment = attachment;
424
+ const referencesFileId = url.searchParams.get('referencesFileId');
425
+ if (referencesFileId)
426
+ filter.referencesFileId = referencesFileId;
277
427
  const relatedTo = parseRelatedToParams(url);
278
428
  if (relatedTo)
279
429
  filter.relatedTo = relatedTo;
@@ -296,9 +446,9 @@ export function parseQueryParams(url) {
296
446
  ...(updatedAfter && { after: requireDate(updatedAfter, 'updatedAfter') }),
297
447
  };
298
448
  }
299
- if (url.searchParams.get('includeDeleted') === 'true')
449
+ if (booleanParam(url, 'includeDeleted'))
300
450
  filter.includeDeleted = true;
301
- if (url.searchParams.get('includeUnlisted') === 'true')
451
+ if (booleanParam(url, 'includeUnlisted'))
302
452
  filter.includeUnlisted = true;
303
453
  const query = {};
304
454
  if (Object.keys(filter).length)
@@ -320,21 +470,26 @@ export function parseQueryParams(url) {
320
470
  /** Validate a limit that already arrived as a JSON number. */
321
471
  function parseLimitValue(raw) {
322
472
  if (typeof raw !== 'number' || !Number.isInteger(raw) || raw <= 0)
323
- throw new StackQueryError(`Invalid limit: ${JSON.stringify(raw)}`);
473
+ throw new StackBadRequestError(`Invalid limit: ${JSON.stringify(raw)}`);
324
474
  return raw;
325
475
  }
326
476
  /**
327
477
  * Build a `StackQuery` from a `POST /records/query` JSON body — the
328
478
  * superset form, which additionally carries `filter.content`. Dates arrive
329
479
  * as the ISO strings `JSON.stringify` made of them and are decoded back to
330
- * `Date`. See docs/spec/wire-format.md § Records.
480
+ * `Date`. `undefined` is the absent body; `null` or any other non-object
481
+ * is refused, so a server hands over no body as `undefined`.
482
+ * See docs/spec/wire-format.md § Records.
331
483
  */
332
484
  export function parseQueryBody(raw) {
333
- if (!raw || typeof raw !== 'object')
485
+ if (raw === undefined)
334
486
  return {};
335
- const body = raw;
487
+ const body = requirePlainObject(raw, 'query body');
336
488
  const f = body.filter !== undefined ? requirePlainObject(body.filter, 'filter') : undefined;
337
489
  assertNoUntravelableFields(f !== undefined && 'baseId' in f, 'presentAt' in body);
490
+ requireKnownKeys(body, ['filter', 'sort', 'limit', 'cursor'], 'query body');
491
+ if (f)
492
+ requireKnownKeys(f, QUERY_BODY_FILTER_KEYS, 'filter');
338
493
  const query = {};
339
494
  if (f) {
340
495
  const filter = {};
@@ -344,16 +499,23 @@ export function parseQueryBody(raw) {
344
499
  filter.parentId = f.parentId === null ? null : requireString(f.parentId, 'filter.parentId');
345
500
  if (f.appId !== undefined)
346
501
  filter.appId = requireStringOrArray(f.appId, 'filter.appId');
347
- if (f.entityId !== undefined)
348
- filter.entityId = requireStringOrArray(f.entityId, 'filter.entityId');
349
- if (f.principalId !== undefined)
350
- filter.principalId = requireStringOrArray(f.principalId, 'filter.principalId');
502
+ if (f.createdBy !== undefined) {
503
+ const by = requireKnownKeys(f.createdBy, ['subjectId', 'principalId'], 'filter.createdBy');
504
+ filter.createdBy = {
505
+ ...(by.subjectId !== undefined && {
506
+ subjectId: requireStringOrArray(by.subjectId, 'filter.createdBy.subjectId'),
507
+ }),
508
+ ...(by.principalId !== undefined && {
509
+ principalId: requireStringOrArray(by.principalId, 'filter.createdBy.principalId'),
510
+ }),
511
+ };
512
+ }
351
513
  if (f.tags !== undefined)
352
514
  filter.tags = requireStringArray(f.tags, 'filter.tags');
353
- if (f.hasAttachment !== undefined)
354
- filter.hasAttachment = requireString(f.hasAttachment, 'filter.hasAttachment');
355
- if (f.attachmentFileId !== undefined)
356
- filter.attachmentFileId = requireString(f.attachmentFileId, 'filter.attachmentFileId');
515
+ if (f.attachment !== undefined)
516
+ filter.attachment = parseAttachmentBody(f.attachment);
517
+ if (f.referencesFileId !== undefined)
518
+ filter.referencesFileId = requireString(f.referencesFileId, 'filter.referencesFileId');
357
519
  if (f.relatedTo !== undefined)
358
520
  filter.relatedTo = parseRelatedToBody(f.relatedTo);
359
521
  if (f.content !== undefined)
@@ -362,19 +524,19 @@ export function parseQueryBody(raw) {
362
524
  filter.contentPresent = requireStringArray(f.contentPresent, 'filter.contentPresent');
363
525
  if (f.search !== undefined)
364
526
  filter.search = requireString(f.search, 'filter.search');
365
- if (f.includeDeleted)
527
+ if (optionalBoolean(f.includeDeleted, 'filter.includeDeleted'))
366
528
  filter.includeDeleted = true;
367
- if (f.includeUnlisted)
529
+ if (optionalBoolean(f.includeUnlisted, 'filter.includeUnlisted'))
368
530
  filter.includeUnlisted = true;
369
- if (f.createdAt) {
370
- const r = requirePlainObject(f.createdAt, 'filter.createdAt');
531
+ if (f.createdAt !== undefined) {
532
+ const r = requireKnownKeys(f.createdAt, ['before', 'after'], 'filter.createdAt');
371
533
  filter.createdAt = {
372
534
  ...(r.before !== undefined && { before: requireDate(r.before, 'filter.createdAt.before') }),
373
535
  ...(r.after !== undefined && { after: requireDate(r.after, 'filter.createdAt.after') }),
374
536
  };
375
537
  }
376
- if (f.updatedAt) {
377
- const r = requirePlainObject(f.updatedAt, 'filter.updatedAt');
538
+ if (f.updatedAt !== undefined) {
539
+ const r = requireKnownKeys(f.updatedAt, ['before', 'after'], 'filter.updatedAt');
378
540
  filter.updatedAt = {
379
541
  ...(r.before !== undefined && { before: requireDate(r.before, 'filter.updatedAt.before') }),
380
542
  ...(r.after !== undefined && { after: requireDate(r.after, 'filter.updatedAt.after') }),
@@ -382,17 +544,17 @@ export function parseQueryBody(raw) {
382
544
  }
383
545
  query.filter = filter;
384
546
  }
385
- if (body.sort) {
386
- const s = requirePlainObject(body.sort, 'sort');
547
+ if (body.sort !== undefined) {
548
+ const s = requireKnownKeys(body.sort, ['field', 'contentField', 'direction'], 'sort');
387
549
  const sort = buildSort(s.field, s.contentField, s.direction);
388
550
  if (!sort)
389
- throw new StackQueryError('Invalid sort: expected a field or a contentField.');
551
+ throw new StackBadRequestError('Invalid sort: expected a field or a contentField.');
390
552
  query.sort = sort;
391
553
  }
392
554
  if (body.limit !== undefined)
393
555
  query.limit = parseLimitValue(body.limit);
394
- if (typeof body.cursor === 'string')
395
- query.cursor = body.cursor;
556
+ if (body.cursor !== undefined)
557
+ query.cursor = requireString(body.cursor, 'cursor');
396
558
  return query;
397
559
  }
398
560
  /**
@@ -401,31 +563,35 @@ export function parseQueryBody(raw) {
401
563
  * the server's own resumption machinery rather than request encoding.
402
564
  */
403
565
  export function parseChangeParams(url) {
566
+ requireKnownParams(url, CHANGE_PARAMS);
404
567
  const filter = {};
405
568
  const typeIds = url.searchParams.getAll('typeId');
406
569
  if (typeIds.length)
407
570
  filter.typeId = typeIds.length === 1 ? typeIds[0] : typeIds;
571
+ const baseIds = url.searchParams.getAll('baseId');
572
+ if (baseIds.length)
573
+ filter.baseId = baseIds.length === 1 ? baseIds[0] : baseIds;
408
574
  const parentId = url.searchParams.get('parentId');
409
575
  if (parentId !== null)
410
576
  filter.parentId = parentId === 'null' ? null : parentId;
411
- const entityId = url.searchParams.get('entityId');
412
- if (entityId !== null)
413
- filter.entityId = entityId;
577
+ const createdBy = parseCreatedByParams(url);
578
+ if (createdBy)
579
+ filter.createdBy = createdBy;
414
580
  const kinds = url.searchParams.getAll('kind');
415
581
  if (kinds.length) {
416
582
  for (const kind of kinds) {
417
583
  if (!CHANGE_KINDS.has(kind))
418
- throw new StackQueryError(`Invalid kind: "${kind}"`);
584
+ throw new StackBadRequestError(`Invalid kind: "${kind}"`);
419
585
  }
420
586
  filter.kinds = kinds;
421
587
  }
422
588
  const include = url.searchParams.get('include');
423
589
  if (include !== null && include !== 'record')
424
- throw new StackQueryError(`Invalid include: "${include}"`);
590
+ throw new StackBadRequestError(`Invalid include: "${include}"`);
425
591
  return {
426
592
  filter,
427
593
  includeRecords: include === 'record',
428
- includeUnlisted: url.searchParams.get('includeUnlisted') === 'true',
594
+ includeUnlisted: booleanParam(url, 'includeUnlisted'),
429
595
  };
430
596
  }
431
597
  /**
@@ -434,20 +600,63 @@ export function parseChangeParams(url) {
434
600
  * omitting `limit` reads the whole log by contract, so supplying a page
435
601
  * size here would truncate exactly the caller that omitted it.
436
602
  *
437
- * `sinceSeq` is a journal `seq` — a dense per-record integer, never the
603
+ * `afterSeq` is a journal `seq` — a dense per-record integer, never the
438
604
  * change feed's opaque cursor. See docs/spec/wire-format.md § Journal.
439
605
  */
440
606
  export function parseJournalParams(url) {
607
+ requireKnownParams(url, ['afterSeq', 'limit']);
441
608
  const query = {};
442
- const sinceSeq = url.searchParams.get('sinceSeq');
443
- if (sinceSeq !== null)
444
- query.sinceSeq = parsePositiveInt(sinceSeq, 'sinceSeq');
609
+ const afterSeq = url.searchParams.get('afterSeq');
610
+ if (afterSeq !== null)
611
+ query.afterSeq = parsePositiveInt(afterSeq, 'afterSeq');
445
612
  const limit = url.searchParams.get('limit');
446
613
  if (limit !== null)
447
614
  query.limit = parsePositiveInt(limit, 'limit');
448
615
  return query;
449
616
  }
450
617
  // -------------------------------------------------------
618
+ // DELETE /records/:id
619
+ // -------------------------------------------------------
620
+ /**
621
+ * Parse `DELETE /records/:id`'s query params into the `purge` flag. A
622
+ * misspelled or non-boolean `purge` is refused rather than read as a soft
623
+ * delete. See docs/spec/wire-format.md § Records.
624
+ */
625
+ export function parseDeleteParams(url) {
626
+ requireKnownParams(url, ['purge']);
627
+ return { purge: booleanParam(url, 'purge') };
628
+ }
629
+ /** Parse `GET /attachments/:fileId`'s query params. See docs/spec/wire-format.md § Download. */
630
+ export function parseDownloadParams(url) {
631
+ requireKnownParams(url, ['contentType', 'filename']);
632
+ const contentType = url.searchParams.get('contentType');
633
+ const filename = url.searchParams.get('filename');
634
+ return {
635
+ ...(contentType !== null && { contentTypeParam: contentType }),
636
+ ...(filename !== null && { filenameParam: filename }),
637
+ };
638
+ }
639
+ /**
640
+ * Parse `GET /records/:id/associations`' query params. An unrecognized
641
+ * `kind` is refused, since reading it as absent would answer with every
642
+ * kind. `kind` repeats on `GET /changes` but names one value here, so a
643
+ * repeat is refused rather than read as its first.
644
+ * See docs/spec/wire-format.md § Associations.
645
+ */
646
+ export function parseAssociationParams(url) {
647
+ requireKnownParams(url, ['kind', 'label']);
648
+ if (url.searchParams.getAll('kind').length > 1)
649
+ throw new StackBadRequestError('Repeated query param: kind');
650
+ const kind = url.searchParams.get('kind');
651
+ if (kind !== null && !ASSOCIATION_KINDS.has(kind))
652
+ throw new StackBadRequestError(`Invalid kind: "${kind}"`);
653
+ const label = url.searchParams.get('label');
654
+ return {
655
+ ...(kind !== null && { kind: kind }),
656
+ ...(label !== null && { label }),
657
+ };
658
+ }
659
+ // -------------------------------------------------------
451
660
  // Headers
452
661
  // -------------------------------------------------------
453
662
  /**