@haverstack/core 0.22.0 → 0.23.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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Stack — Wire Record Bodies
3
+ * -------------------------------------------------------
4
+ * `POST /records` is the one endpoint where a client sends a whole record
5
+ * and the server may trust only part of it. This module turns that body
6
+ * into the arguments `ScopedStack.create()` takes, so the disposition of
7
+ * each field — stamped, conditionally dropped, forwarded — is structural
8
+ * rather than a property of how carefully a route was written. Which field
9
+ * gets which is not derivable from its name, and the rules are normative
10
+ * for servers in other languages, so they live in the spec rather than
11
+ * here: docs/spec/wire-format.md § Records.
12
+ */
13
+ import { isOwnerActingAlone } from './access.js';
14
+ import { StackQueryError, StackValidationError } from './stack.js';
15
+ function requireBody(body) {
16
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
17
+ throw new StackQueryError('Invalid record body: expected an object');
18
+ return body;
19
+ }
20
+ /**
21
+ * The refusal a present field earns when its value is the wrong shape.
22
+ * `StackValidationError` because the failure names a field of the record
23
+ * being written, and only that class carries the path.
24
+ */
25
+ function fieldError(path, message) {
26
+ throw new StackValidationError([{ path, message }]);
27
+ }
28
+ function optionalString(body, key) {
29
+ const value = body[key];
30
+ if (value === undefined)
31
+ return undefined;
32
+ if (typeof value !== 'string')
33
+ fieldError(key, `${key} must be a string`);
34
+ return value;
35
+ }
36
+ function optionalArray(body, key) {
37
+ const value = body[key];
38
+ if (value === undefined)
39
+ return undefined;
40
+ if (!Array.isArray(value))
41
+ fieldError(key, `${key} must be an array`);
42
+ return value;
43
+ }
44
+ /**
45
+ * A wire date for one of the owner-only clock fields. An `Invalid Date`
46
+ * must not reach `create()`: its `NaN` timestamp switches the id-skew
47
+ * check off instead of failing it.
48
+ */
49
+ function ownerDate(body, key) {
50
+ const value = body[key];
51
+ if (value === undefined)
52
+ return undefined;
53
+ if (typeof value !== 'string' && typeof value !== 'number')
54
+ fieldError(key, `${key} must be a date string`);
55
+ const date = new Date(value);
56
+ if (isNaN(date.getTime()))
57
+ fieldError(key, `Invalid ${key}: ${JSON.stringify(value)}`);
58
+ return date;
59
+ }
60
+ /**
61
+ * Read a `POST /records` body as a create. Takes the session rather than a
62
+ * pre-computed boolean so the owner-acting-alone determination stays with
63
+ * the rule that needs it.
64
+ *
65
+ * Beyond shape, values are `Stack`'s to judge — an id's legality, a
66
+ * permission's contents, the content's schema — since judging them twice
67
+ * would let the two answers drift.
68
+ */
69
+ export function createOptionsFromWireRecord(body, session, ownerEntityId) {
70
+ const record = requireBody(body);
71
+ const typeId = record.typeId;
72
+ if (typeof typeId !== 'string' || typeId === '')
73
+ throw new StackQueryError('Invalid record body: typeId is required');
74
+ const content = record.content;
75
+ if (typeof content !== 'object' || content === null || Array.isArray(content))
76
+ throw new StackQueryError('Invalid record body: content is required');
77
+ const options = {};
78
+ const id = optionalString(record, 'id');
79
+ if (id !== undefined)
80
+ options.id = id;
81
+ const parentId = optionalString(record, 'parentId');
82
+ if (parentId !== undefined)
83
+ options.parentId = parentId;
84
+ const appId = optionalString(record, 'appId');
85
+ if (appId !== undefined)
86
+ options.appId = appId;
87
+ const permissions = optionalArray(record, 'permissions');
88
+ if (permissions !== undefined)
89
+ options.permissions = permissions;
90
+ const associations = optionalArray(record, 'associations');
91
+ if (associations !== undefined)
92
+ options.associations = associations;
93
+ // Presence is the whole signal: an unlisted record is unlisted from
94
+ // birth, so the timestamp a client sends has nothing to say.
95
+ if (optionalString(record, 'unlistedAt') !== undefined)
96
+ options.unlisted = true;
97
+ if (isOwnerActingAlone(session, ownerEntityId)) {
98
+ const createdAt = ownerDate(record, 'createdAt');
99
+ if (createdAt !== undefined)
100
+ options.createdAt = createdAt;
101
+ const updatedAt = ownerDate(record, 'updatedAt');
102
+ if (updatedAt !== undefined)
103
+ options.updatedAt = updatedAt;
104
+ }
105
+ return { typeId, content: content, options };
106
+ }
107
+ //# sourceMappingURL=wire-record.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wire-record.js","sourceRoot":"","sources":["../src/wire-record.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Stack — Wire Request Parsing
3
+ * -------------------------------------------------------
4
+ * The parse half of the request encoding `adapter-api` builds: URL search
5
+ * params and JSON bodies in, the core types `Stack` takes out. Core has no
6
+ * HTTP server of its own — this module exists so every server
7
+ * implementation shares one decoding of the query grammar rather than
8
+ * transcribing docs/spec/wire-format.md § Records' parameter table and
9
+ * re-deriving its rules, which is where a miss produces a query that
10
+ * silently widens rather than one that fails.
11
+ *
12
+ * Three things this deliberately does not do:
13
+ *
14
+ * - **No clamping.** A `limit` ceiling is deployment policy, not wire
15
+ * contract, so the requested limit is reported as asked and the server
16
+ * applies its own cap afterwards.
17
+ * - **No capability gating.** `assertQueryCapabilities()` in
18
+ * `@haverstack/core/adapter` stays a separate, deliberate call.
19
+ * - **No value validation beyond shape.** An ID's or a cursor's legality is
20
+ * `Stack`'s to judge, and judging it twice would let the two answers
21
+ * drift.
22
+ *
23
+ * Everything here throws `StackQueryError` on malformed input, which
24
+ * servers already map to 400 — see docs/spec/wire-format.md § Error
25
+ * responses.
26
+ */
27
+ import type { ChangeFilter, StackQuery } from './types.js';
28
+ /** Parse an ISO date string from a wire body; undefined if absent or invalid. */
29
+ export declare function parseDate(val: unknown): Date | undefined;
30
+ /**
31
+ * Strict positive-integer parse for a URL param — rejects "1abc", "2.7",
32
+ * "-5". Also serves the `:version` path params, which share this
33
+ * "malformed, don't silently coerce" requirement.
34
+ */
35
+ export declare function parsePositiveInt(raw: string, label: string): number;
36
+ /**
37
+ * Build a `StackQuery` from `GET /records` search params — the inverse of
38
+ * what `APIAdapter` encodes for a server without `contentFieldQuery`.
39
+ * `parentId=null` is the literal string, the sentinel for "top level":
40
+ * Crockford base-32 excludes `u` and `l`, so no record ID can collide
41
+ * with it. See docs/spec/wire-format.md § Records.
42
+ */
43
+ export declare function parseQueryParams(url: URL): StackQuery;
44
+ /**
45
+ * Build a `StackQuery` from a `POST /records/query` JSON body — the
46
+ * superset form, which additionally carries `filter.content`. Dates arrive
47
+ * as the ISO strings `JSON.stringify` made of them and are decoded back to
48
+ * `Date`. See docs/spec/wire-format.md § Records.
49
+ */
50
+ export declare function parseQueryBody(raw: unknown): StackQuery;
51
+ /**
52
+ * What `GET /changes`' query params carry. One object rather than three
53
+ * exports because `includeUnlisted` sits on `SubscribeOptions` rather than
54
+ * `ChangeFilter`, and a server needs it before the stream opens to answer
55
+ * the owner-only 403. See docs/spec/change-feed.md.
56
+ */
57
+ export type ParsedChangeParams = {
58
+ filter: ChangeFilter;
59
+ includeRecords: boolean;
60
+ includeUnlisted: boolean;
61
+ };
62
+ /**
63
+ * Parse `GET /changes`' query params. The resume cursor is deliberately
64
+ * absent: `Last-Event-ID` outranks `?since=`, and reconciling the two is
65
+ * the server's own resumption machinery rather than request encoding.
66
+ */
67
+ export declare function parseChangeParams(url: URL): ParsedChangeParams;
68
+ /**
69
+ * Parse an `If-Match: "5"` header into the version for `ifVersion`.
70
+ * A value that is not a bare, optionally quoted version is refused rather
71
+ * than read as absent: the header exists to fence a write, so degrading a
72
+ * malformed one to an unconditional last-writer-wins mutation defeats the
73
+ * only thing it was sent to do. Weak comparators (`W/"5"`) are refused for
74
+ * the same reason — a version match is exact or it is nothing.
75
+ * See docs/spec/wire-format.md § Records.
76
+ */
77
+ export declare function parseIfMatch(header: string | undefined): number | undefined;
78
+ /**
79
+ * Read an upload's filename from its `Content-Disposition`. Prefers the
80
+ * RFC 5987 extended form (`filename*=UTF-8''name.txt`), which is what a
81
+ * client carrying a non-ASCII name sends, and falls back to the plain
82
+ * quoted form. See docs/spec/wire-format.md § Upload.
83
+ */
84
+ export declare function parseUploadFilename(header: string | undefined): string | undefined;
85
+ //# sourceMappingURL=wire-request.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wire-request.d.ts","sourceRoot":"","sources":["../src/wire-request.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAGH,OAAO,KAAK,EACV,YAAY,EAMZ,UAAU,EACX,MAAM,YAAY,CAAC;AAMpB,iFAAiF;AACjF,wBAAgB,SAAS,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI,GAAG,SAAS,CAIxD;AAQD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAGnE;AAwKD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,GAAG,GAAG,UAAU,CA2ErD;AAaD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,GAAG,UAAU,CA4DvD;AAMD;;;;;GAKG;AACH,MAAM,MAAM,kBAAkB,GAAG;IAC/B,MAAM,EAAE,YAAY,CAAC;IACrB,cAAc,EAAE,OAAO,CAAC;IACxB,eAAe,EAAE,OAAO,CAAC;CAC1B,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,GAAG,GAAG,kBAAkB,CA8B9D;AAMD;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAK3E;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAYlF"}
@@ -0,0 +1,435 @@
1
+ /**
2
+ * Stack — Wire Request Parsing
3
+ * -------------------------------------------------------
4
+ * The parse half of the request encoding `adapter-api` builds: URL search
5
+ * params and JSON bodies in, the core types `Stack` takes out. Core has no
6
+ * HTTP server of its own — this module exists so every server
7
+ * implementation shares one decoding of the query grammar rather than
8
+ * transcribing docs/spec/wire-format.md § Records' parameter table and
9
+ * re-deriving its rules, which is where a miss produces a query that
10
+ * silently widens rather than one that fails.
11
+ *
12
+ * Three things this deliberately does not do:
13
+ *
14
+ * - **No clamping.** A `limit` ceiling is deployment policy, not wire
15
+ * contract, so the requested limit is reported as asked and the server
16
+ * applies its own cap afterwards.
17
+ * - **No capability gating.** `assertQueryCapabilities()` in
18
+ * `@haverstack/core/adapter` stays a separate, deliberate call.
19
+ * - **No value validation beyond shape.** An ID's or a cursor's legality is
20
+ * `Stack`'s to judge, and judging it twice would let the two answers
21
+ * drift.
22
+ *
23
+ * Everything here throws `StackQueryError` on malformed input, which
24
+ * servers already map to 400 — see docs/spec/wire-format.md § Error
25
+ * responses.
26
+ */
27
+ import { StackQueryError } from './stack.js';
28
+ // -------------------------------------------------------
29
+ // Shared primitives
30
+ // -------------------------------------------------------
31
+ /** Parse an ISO date string from a wire body; undefined if absent or invalid. */
32
+ export function parseDate(val) {
33
+ if (typeof val !== 'string')
34
+ return undefined;
35
+ const d = new Date(val);
36
+ return isNaN(d.getTime()) ? undefined : d;
37
+ }
38
+ const POSITIVE_INTEGER = /^\d+$/;
39
+ const SORT_FIELDS = new Set(['createdAt', 'updatedAt', 'version']);
40
+ const SORT_DIRECTIONS = new Set(['asc', 'desc']);
41
+ const CHANGE_KINDS = new Set(['created', 'changed', 'deleted', 'purged']);
42
+ const TARGET_SCOPES = new Set(['record', 'entity', 'external']);
43
+ /**
44
+ * Strict positive-integer parse for a URL param — rejects "1abc", "2.7",
45
+ * "-5". Also serves the `:version` path params, which share this
46
+ * "malformed, don't silently coerce" requirement.
47
+ */
48
+ export function parsePositiveInt(raw, label) {
49
+ if (!POSITIVE_INTEGER.test(raw))
50
+ throw new StackQueryError(`Invalid ${label}: "${raw}"`);
51
+ return parseInt(raw, 10);
52
+ }
53
+ function requireDate(raw, label) {
54
+ const d = parseDate(raw);
55
+ if (!d)
56
+ throw new StackQueryError(`Invalid ${label}: ${JSON.stringify(raw)}`);
57
+ return d;
58
+ }
59
+ function requireSortField(raw) {
60
+ if (typeof raw !== 'string' || !SORT_FIELDS.has(raw))
61
+ throw new StackQueryError(`Invalid sort field: ${JSON.stringify(raw)}`);
62
+ return raw;
63
+ }
64
+ function requireSortDirection(raw) {
65
+ if (typeof raw !== 'string' || !SORT_DIRECTIONS.has(raw))
66
+ throw new StackQueryError(`Invalid sort direction: ${JSON.stringify(raw)}`);
67
+ return raw;
68
+ }
69
+ function requireString(raw, label) {
70
+ if (typeof raw !== 'string')
71
+ throw new StackQueryError(`Invalid ${label}: expected a string`);
72
+ return raw;
73
+ }
74
+ function requireStringOrArray(raw, label) {
75
+ if (typeof raw === 'string')
76
+ return raw;
77
+ if (Array.isArray(raw) && raw.every((v) => typeof v === 'string'))
78
+ return raw;
79
+ throw new StackQueryError(`Invalid ${label}: expected a string or array of strings`);
80
+ }
81
+ function requireStringArray(raw, label) {
82
+ if (!Array.isArray(raw) || !raw.every((v) => typeof v === 'string'))
83
+ throw new StackQueryError(`Invalid ${label}: expected an array of strings`);
84
+ return raw;
85
+ }
86
+ function requirePlainObject(raw, label) {
87
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
88
+ throw new StackQueryError(`Invalid ${label}: expected an object`);
89
+ return raw;
90
+ }
91
+ // -------------------------------------------------------
92
+ // Filter fields that never travel
93
+ // -------------------------------------------------------
94
+ const BASE_ID_REFUSAL = 'baseId is resolved client-side against registered Types and never travels — send the ' +
95
+ 'concrete typeId set it resolves to. See docs/spec/data-model.md § Filter.';
96
+ const PRESENT_AT_REFUSAL = 'presentAt applies migration functions, which are app code rather than server code, so no ' +
97
+ 'server can honor it — read records as stored and migrate them client-side. See ' +
98
+ 'docs/spec/data-model.md § Type migrations.';
99
+ /**
100
+ * Refuse the two `StackQuery` fields with no wire encoding, rather than
101
+ * dropping them. Both would otherwise return a result the caller believes
102
+ * is narrower (`baseId`) or migrated (`presentAt`) than what it holds —
103
+ * the same silent degradation `assertQueryCapabilities()` refuses for a
104
+ * capability-gated filter. See docs/spec/wire-format.md § Records.
105
+ */
106
+ function assertNoUntravelableFields(hasBaseId, hasPresentAt) {
107
+ if (hasBaseId)
108
+ throw new StackQueryError(BASE_ID_REFUSAL);
109
+ if (hasPresentAt)
110
+ throw new StackQueryError(PRESENT_AT_REFUSAL);
111
+ }
112
+ // -------------------------------------------------------
113
+ // Relationship filter
114
+ // -------------------------------------------------------
115
+ /**
116
+ * Route a `filter.relatedTo.target` by scope. Core validates the
117
+ * non-emptiness of whichever fields the scope names, so this only rejects
118
+ * a scope it does not recognize.
119
+ */
120
+ function parseRelatedToTarget(raw) {
121
+ const t = requirePlainObject(raw, 'filter.relatedTo.target');
122
+ if (typeof t.scope !== 'string' || !TARGET_SCOPES.has(t.scope))
123
+ throw new StackQueryError(`Invalid filter.relatedTo.target.scope: ${JSON.stringify(t.scope)}`);
124
+ if (t.scope === 'record') {
125
+ return {
126
+ scope: 'record',
127
+ recordId: requireString(t.recordId, 'filter.relatedTo.target.recordId'),
128
+ ...(t.stackUrl !== undefined && {
129
+ stackUrl: requireString(t.stackUrl, 'filter.relatedTo.target.stackUrl'),
130
+ }),
131
+ };
132
+ }
133
+ if (t.scope === 'entity') {
134
+ return {
135
+ scope: 'entity',
136
+ entityId: requireString(t.entityId, 'filter.relatedTo.target.entityId'),
137
+ };
138
+ }
139
+ return {
140
+ scope: 'external',
141
+ ns: requireString(t.ns, 'filter.relatedTo.target.ns'),
142
+ ...(t.id !== undefined && { id: requireString(t.id, 'filter.relatedTo.target.id') }),
143
+ };
144
+ }
145
+ /** A label, a target, or both — the body form of the same filter. */
146
+ function parseRelatedToBody(raw) {
147
+ const r = requirePlainObject(raw, 'filter.relatedTo');
148
+ const label = r.label !== undefined ? requireString(r.label, 'filter.relatedTo.label') : undefined;
149
+ const target = r.target !== undefined ? parseRelatedToTarget(r.target) : undefined;
150
+ return {
151
+ ...(label !== undefined && { label }),
152
+ ...(target !== undefined && { target }),
153
+ };
154
+ }
155
+ /**
156
+ * The `relatedTo*` URL params. Scope is implied by which of them appear and
157
+ * the three are mutually exclusive. An empty value passes through raw
158
+ * rather than as absent — that is what lets core's target validation tell
159
+ * "omitted" from "empty" for `relatedToStack` and `relatedToId`, where the
160
+ * two mean different things and neither is a wildcard.
161
+ * See docs/spec/wire-format.md § Records.
162
+ */
163
+ function parseRelatedToParams(url) {
164
+ const hasRecord = url.searchParams.has('relatedTo');
165
+ const hasStack = url.searchParams.has('relatedToStack');
166
+ const hasEntity = url.searchParams.has('relatedToEntity');
167
+ const hasNs = url.searchParams.has('relatedToNs');
168
+ const hasId = url.searchParams.has('relatedToId');
169
+ const label = url.searchParams.get('relatedToLabel');
170
+ if ([hasRecord, hasEntity, hasNs].filter(Boolean).length > 1) {
171
+ throw new StackQueryError('relatedTo, relatedToEntity and relatedToNs name different target scopes and are mutually exclusive');
172
+ }
173
+ if (hasStack && !hasRecord)
174
+ throw new StackQueryError('relatedToStack is only valid alongside relatedTo');
175
+ if (hasId && !hasNs)
176
+ throw new StackQueryError('relatedToId is only valid alongside relatedToNs');
177
+ let target;
178
+ if (hasRecord) {
179
+ target = {
180
+ scope: 'record',
181
+ recordId: url.searchParams.get('relatedTo'),
182
+ ...(hasStack && { stackUrl: url.searchParams.get('relatedToStack') }),
183
+ };
184
+ }
185
+ else if (hasEntity) {
186
+ target = { scope: 'entity', entityId: url.searchParams.get('relatedToEntity') };
187
+ }
188
+ else if (hasNs) {
189
+ target = {
190
+ scope: 'external',
191
+ ns: url.searchParams.get('relatedToNs'),
192
+ ...(hasId && { id: url.searchParams.get('relatedToId') }),
193
+ };
194
+ }
195
+ if (target === undefined && !label)
196
+ return undefined;
197
+ return {
198
+ ...(target !== undefined && { target }),
199
+ ...(label && { label }),
200
+ };
201
+ }
202
+ // -------------------------------------------------------
203
+ // GET /records
204
+ // -------------------------------------------------------
205
+ /**
206
+ * Build a `StackQuery` from `GET /records` search params — the inverse of
207
+ * what `APIAdapter` encodes for a server without `contentFieldQuery`.
208
+ * `parentId=null` is the literal string, the sentinel for "top level":
209
+ * Crockford base-32 excludes `u` and `l`, so no record ID can collide
210
+ * with it. See docs/spec/wire-format.md § Records.
211
+ */
212
+ export function parseQueryParams(url) {
213
+ assertNoUntravelableFields(url.searchParams.has('baseId'), url.searchParams.has('presentAt'));
214
+ const filter = {};
215
+ const typeIds = url.searchParams.getAll('typeId');
216
+ if (typeIds.length)
217
+ filter.typeId = typeIds.length === 1 ? typeIds[0] : typeIds;
218
+ const parentId = url.searchParams.get('parentId');
219
+ if (parentId !== null)
220
+ filter.parentId = parentId === 'null' ? null : parentId;
221
+ const appIds = url.searchParams.getAll('appId');
222
+ if (appIds.length)
223
+ filter.appId = appIds.length === 1 ? appIds[0] : appIds;
224
+ const entityIds = url.searchParams.getAll('entityId');
225
+ if (entityIds.length)
226
+ filter.entityId = entityIds.length === 1 ? entityIds[0] : entityIds;
227
+ const principalIds = url.searchParams.getAll('principalId');
228
+ if (principalIds.length)
229
+ filter.principalId = principalIds.length === 1 ? principalIds[0] : principalIds;
230
+ const tags = url.searchParams.getAll('tag');
231
+ if (tags.length)
232
+ filter.tags = tags;
233
+ const hasAttachment = url.searchParams.get('hasAttachment');
234
+ if (hasAttachment)
235
+ filter.hasAttachment = hasAttachment;
236
+ const attachmentFileId = url.searchParams.get('attachmentFileId');
237
+ if (attachmentFileId)
238
+ filter.attachmentFileId = attachmentFileId;
239
+ const relatedTo = parseRelatedToParams(url);
240
+ if (relatedTo)
241
+ filter.relatedTo = relatedTo;
242
+ const search = url.searchParams.get('search');
243
+ if (search)
244
+ filter.search = search;
245
+ const createdBefore = url.searchParams.get('createdBefore');
246
+ const createdAfter = url.searchParams.get('createdAfter');
247
+ if (createdBefore || createdAfter) {
248
+ filter.createdAt = {
249
+ ...(createdBefore && { before: requireDate(createdBefore, 'createdBefore') }),
250
+ ...(createdAfter && { after: requireDate(createdAfter, 'createdAfter') }),
251
+ };
252
+ }
253
+ const updatedBefore = url.searchParams.get('updatedBefore');
254
+ const updatedAfter = url.searchParams.get('updatedAfter');
255
+ if (updatedBefore || updatedAfter) {
256
+ filter.updatedAt = {
257
+ ...(updatedBefore && { before: requireDate(updatedBefore, 'updatedBefore') }),
258
+ ...(updatedAfter && { after: requireDate(updatedAfter, 'updatedAfter') }),
259
+ };
260
+ }
261
+ if (url.searchParams.get('includeDeleted') === 'true')
262
+ filter.includeDeleted = true;
263
+ if (url.searchParams.get('includeUnlisted') === 'true')
264
+ filter.includeUnlisted = true;
265
+ const query = {};
266
+ if (Object.keys(filter).length)
267
+ query.filter = filter;
268
+ const sort = url.searchParams.get('sort');
269
+ const direction = url.searchParams.get('direction');
270
+ if (sort)
271
+ query.sort = {
272
+ field: requireSortField(sort),
273
+ ...(direction && { direction: requireSortDirection(direction) }),
274
+ };
275
+ const limit = url.searchParams.get('limit');
276
+ if (limit)
277
+ query.limit = parsePositiveInt(limit, 'limit');
278
+ const cursor = url.searchParams.get('cursor');
279
+ if (cursor)
280
+ query.cursor = cursor;
281
+ return query;
282
+ }
283
+ // -------------------------------------------------------
284
+ // POST /records/query
285
+ // -------------------------------------------------------
286
+ /** Validate a limit that already arrived as a JSON number. */
287
+ function parseLimitValue(raw) {
288
+ if (typeof raw !== 'number' || !Number.isInteger(raw) || raw <= 0)
289
+ throw new StackQueryError(`Invalid limit: ${JSON.stringify(raw)}`);
290
+ return raw;
291
+ }
292
+ /**
293
+ * Build a `StackQuery` from a `POST /records/query` JSON body — the
294
+ * superset form, which additionally carries `filter.content`. Dates arrive
295
+ * as the ISO strings `JSON.stringify` made of them and are decoded back to
296
+ * `Date`. See docs/spec/wire-format.md § Records.
297
+ */
298
+ export function parseQueryBody(raw) {
299
+ if (!raw || typeof raw !== 'object')
300
+ return {};
301
+ const body = raw;
302
+ const f = body.filter !== undefined ? requirePlainObject(body.filter, 'filter') : undefined;
303
+ assertNoUntravelableFields(f !== undefined && 'baseId' in f, 'presentAt' in body);
304
+ const query = {};
305
+ if (f) {
306
+ const filter = {};
307
+ if (f.typeId !== undefined)
308
+ filter.typeId = requireStringOrArray(f.typeId, 'filter.typeId');
309
+ if (f.parentId !== undefined)
310
+ filter.parentId = f.parentId === null ? null : requireString(f.parentId, 'filter.parentId');
311
+ if (f.appId !== undefined)
312
+ filter.appId = requireStringOrArray(f.appId, 'filter.appId');
313
+ if (f.entityId !== undefined)
314
+ filter.entityId = requireStringOrArray(f.entityId, 'filter.entityId');
315
+ if (f.principalId !== undefined)
316
+ filter.principalId = requireStringOrArray(f.principalId, 'filter.principalId');
317
+ if (f.tags !== undefined)
318
+ filter.tags = requireStringArray(f.tags, 'filter.tags');
319
+ if (f.hasAttachment !== undefined)
320
+ filter.hasAttachment = requireString(f.hasAttachment, 'filter.hasAttachment');
321
+ if (f.attachmentFileId !== undefined)
322
+ filter.attachmentFileId = requireString(f.attachmentFileId, 'filter.attachmentFileId');
323
+ if (f.relatedTo !== undefined)
324
+ filter.relatedTo = parseRelatedToBody(f.relatedTo);
325
+ if (f.content !== undefined)
326
+ filter.content = requirePlainObject(f.content, 'filter.content');
327
+ if (f.search !== undefined)
328
+ filter.search = requireString(f.search, 'filter.search');
329
+ if (f.includeDeleted)
330
+ filter.includeDeleted = true;
331
+ if (f.includeUnlisted)
332
+ filter.includeUnlisted = true;
333
+ if (f.createdAt) {
334
+ const r = requirePlainObject(f.createdAt, 'filter.createdAt');
335
+ filter.createdAt = {
336
+ ...(r.before !== undefined && { before: requireDate(r.before, 'filter.createdAt.before') }),
337
+ ...(r.after !== undefined && { after: requireDate(r.after, 'filter.createdAt.after') }),
338
+ };
339
+ }
340
+ if (f.updatedAt) {
341
+ const r = requirePlainObject(f.updatedAt, 'filter.updatedAt');
342
+ filter.updatedAt = {
343
+ ...(r.before !== undefined && { before: requireDate(r.before, 'filter.updatedAt.before') }),
344
+ ...(r.after !== undefined && { after: requireDate(r.after, 'filter.updatedAt.after') }),
345
+ };
346
+ }
347
+ query.filter = filter;
348
+ }
349
+ if (body.sort) {
350
+ const s = requirePlainObject(body.sort, 'sort');
351
+ query.sort = {
352
+ field: requireSortField(s.field),
353
+ ...(s.direction !== undefined && { direction: requireSortDirection(s.direction) }),
354
+ };
355
+ }
356
+ if (body.limit !== undefined)
357
+ query.limit = parseLimitValue(body.limit);
358
+ if (typeof body.cursor === 'string')
359
+ query.cursor = body.cursor;
360
+ return query;
361
+ }
362
+ /**
363
+ * Parse `GET /changes`' query params. The resume cursor is deliberately
364
+ * absent: `Last-Event-ID` outranks `?since=`, and reconciling the two is
365
+ * the server's own resumption machinery rather than request encoding.
366
+ */
367
+ export function parseChangeParams(url) {
368
+ const filter = {};
369
+ const typeIds = url.searchParams.getAll('typeId');
370
+ if (typeIds.length)
371
+ filter.typeId = typeIds.length === 1 ? typeIds[0] : typeIds;
372
+ const parentId = url.searchParams.get('parentId');
373
+ if (parentId !== null)
374
+ filter.parentId = parentId === 'null' ? null : parentId;
375
+ const entityId = url.searchParams.get('entityId');
376
+ if (entityId !== null)
377
+ filter.entityId = entityId;
378
+ const kinds = url.searchParams.getAll('kind');
379
+ if (kinds.length) {
380
+ for (const kind of kinds) {
381
+ if (!CHANGE_KINDS.has(kind))
382
+ throw new StackQueryError(`Invalid kind: "${kind}"`);
383
+ }
384
+ filter.kinds = kinds;
385
+ }
386
+ const include = url.searchParams.get('include');
387
+ if (include !== null && include !== 'record')
388
+ throw new StackQueryError(`Invalid include: "${include}"`);
389
+ return {
390
+ filter,
391
+ includeRecords: include === 'record',
392
+ includeUnlisted: url.searchParams.get('includeUnlisted') === 'true',
393
+ };
394
+ }
395
+ // -------------------------------------------------------
396
+ // Headers
397
+ // -------------------------------------------------------
398
+ /**
399
+ * Parse an `If-Match: "5"` header into the version for `ifVersion`.
400
+ * A value that is not a bare, optionally quoted version is refused rather
401
+ * than read as absent: the header exists to fence a write, so degrading a
402
+ * malformed one to an unconditional last-writer-wins mutation defeats the
403
+ * only thing it was sent to do. Weak comparators (`W/"5"`) are refused for
404
+ * the same reason — a version match is exact or it is nothing.
405
+ * See docs/spec/wire-format.md § Records.
406
+ */
407
+ export function parseIfMatch(header) {
408
+ if (header === undefined)
409
+ return undefined;
410
+ const trimmed = header.trim();
411
+ const unquoted = /^"(.*)"$/.exec(trimmed)?.[1] ?? trimmed;
412
+ return parsePositiveInt(unquoted, 'If-Match');
413
+ }
414
+ /**
415
+ * Read an upload's filename from its `Content-Disposition`. Prefers the
416
+ * RFC 5987 extended form (`filename*=UTF-8''name.txt`), which is what a
417
+ * client carrying a non-ASCII name sends, and falls back to the plain
418
+ * quoted form. See docs/spec/wire-format.md § Upload.
419
+ */
420
+ export function parseUploadFilename(header) {
421
+ if (!header)
422
+ return undefined;
423
+ const extended = /filename\*\s*=\s*[^']*'[^']*'([^;]+)/i.exec(header);
424
+ if (extended) {
425
+ try {
426
+ return decodeURIComponent(extended[1].trim());
427
+ }
428
+ catch {
429
+ // Malformed percent-encoding — fall through to the plain form.
430
+ }
431
+ }
432
+ const plain = /filename\s*=\s*"([^"]+)"/i.exec(header);
433
+ return plain ? plain[1] : undefined;
434
+ }
435
+ //# sourceMappingURL=wire-request.js.map