@beignet/core 0.0.48 → 0.0.49

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 (89) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +64 -14
  3. package/dist/client/client.d.ts +0 -2
  4. package/dist/client/client.d.ts.map +1 -1
  5. package/dist/client/client.js +28 -25
  6. package/dist/client/client.js.map +1 -1
  7. package/dist/contracts/contract-builder.d.ts +7 -2
  8. package/dist/contracts/contract-builder.d.ts.map +1 -1
  9. package/dist/contracts/contract-builder.js +20 -2
  10. package/dist/contracts/contract-builder.js.map +1 -1
  11. package/dist/contracts/contract-group.d.ts.map +1 -1
  12. package/dist/contracts/contract-group.js +1 -0
  13. package/dist/contracts/contract-group.js.map +1 -1
  14. package/dist/contracts/contract-like.d.ts +2 -0
  15. package/dist/contracts/contract-like.d.ts.map +1 -1
  16. package/dist/contracts/contract-like.js +27 -1
  17. package/dist/contracts/contract-like.js.map +1 -1
  18. package/dist/contracts/index.d.ts +4 -0
  19. package/dist/contracts/index.d.ts.map +1 -1
  20. package/dist/contracts/index.js +4 -0
  21. package/dist/contracts/index.js.map +1 -1
  22. package/dist/contracts/query-transport.d.ts +126 -0
  23. package/dist/contracts/query-transport.d.ts.map +1 -0
  24. package/dist/contracts/query-transport.js +406 -0
  25. package/dist/contracts/query-transport.js.map +1 -0
  26. package/dist/contracts/schema-shape.d.ts +11 -0
  27. package/dist/contracts/schema-shape.d.ts.map +1 -1
  28. package/dist/contracts/schema-shape.js +13 -0
  29. package/dist/contracts/schema-shape.js.map +1 -1
  30. package/dist/contracts/types.d.ts +5 -0
  31. package/dist/contracts/types.d.ts.map +1 -1
  32. package/dist/contracts/types.js.map +1 -1
  33. package/dist/openapi/index.d.ts +8 -0
  34. package/dist/openapi/index.d.ts.map +1 -1
  35. package/dist/openapi/index.js +79 -5
  36. package/dist/openapi/index.js.map +1 -1
  37. package/dist/outbox/index.d.ts +8 -5
  38. package/dist/outbox/index.d.ts.map +1 -1
  39. package/dist/outbox/index.js +17 -3
  40. package/dist/outbox/index.js.map +1 -1
  41. package/dist/ports/cache.d.ts +9 -1
  42. package/dist/ports/cache.d.ts.map +1 -1
  43. package/dist/ports/cache.js +20 -5
  44. package/dist/ports/cache.js.map +1 -1
  45. package/dist/ports/index.d.ts +1 -1
  46. package/dist/ports/index.d.ts.map +1 -1
  47. package/dist/ports/index.js +1 -1
  48. package/dist/ports/index.js.map +1 -1
  49. package/dist/query-codec.d.ts +24 -0
  50. package/dist/query-codec.d.ts.map +1 -1
  51. package/dist/query-codec.js +207 -72
  52. package/dist/query-codec.js.map +1 -1
  53. package/dist/server/hooks/rate-limit.d.ts +16 -8
  54. package/dist/server/hooks/rate-limit.d.ts.map +1 -1
  55. package/dist/server/hooks/rate-limit.js +31 -17
  56. package/dist/server/hooks/rate-limit.js.map +1 -1
  57. package/dist/server/request-executor.d.ts.map +1 -1
  58. package/dist/server/request-executor.js +9 -9
  59. package/dist/server/request-executor.js.map +1 -1
  60. package/dist/server/request-preparation.d.ts.map +1 -1
  61. package/dist/server/request-preparation.js +11 -9
  62. package/dist/server/request-preparation.js.map +1 -1
  63. package/dist/server/server.d.ts.map +1 -1
  64. package/dist/server/server.js +2 -0
  65. package/dist/server/server.js.map +1 -1
  66. package/dist/server/use-case-route.d.ts +8 -5
  67. package/dist/server/use-case-route.d.ts.map +1 -1
  68. package/dist/server/use-case-route.js +44 -17
  69. package/dist/server/use-case-route.js.map +1 -1
  70. package/package.json +3 -3
  71. package/skills/app-architecture/SKILL.md +27 -1
  72. package/src/client/client.ts +29 -28
  73. package/src/contracts/contract-builder.ts +32 -2
  74. package/src/contracts/contract-group.ts +1 -0
  75. package/src/contracts/contract-like.ts +40 -1
  76. package/src/contracts/index.ts +23 -0
  77. package/src/contracts/query-transport.ts +697 -0
  78. package/src/contracts/schema-shape.ts +24 -0
  79. package/src/contracts/types.ts +5 -0
  80. package/src/openapi/index.ts +126 -2
  81. package/src/outbox/index.ts +26 -5
  82. package/src/ports/cache.ts +29 -7
  83. package/src/ports/index.ts +1 -1
  84. package/src/server/hooks/rate-limit.ts +50 -24
  85. package/src/server/request-executor.ts +17 -9
  86. package/src/server/request-preparation.ts +22 -9
  87. package/src/server/server.ts +2 -0
  88. package/src/server/use-case-route.ts +62 -23
  89. package/src/query-codec.ts +0 -130
@@ -0,0 +1,697 @@
1
+ const QUERY_EXTENSION_PREFIX = "~beignet-query:v1:";
2
+ const EMPTY_ARRAY_EXTENSION = `${QUERY_EXTENSION_PREFIX}{"kind":"empty-preserving","value":[]}`;
3
+ const EMPTY_OBJECT_EXTENSION = `${QUERY_EXTENSION_PREFIX}{"kind":"empty-preserving","value":{}}`;
4
+ const DATE_TIME_PATTERN =
5
+ /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?([Zz]|([+-])(\d{2}):(\d{2}))$/;
6
+ const NUMBER_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
7
+ const UNSAFE_QUERY_NAMES = new Set(["__proto__", "constructor", "prototype"]);
8
+
9
+ /** Behavior when a query collection is empty. */
10
+ export type QueryEmptyBehavior = "omit" | "preserve";
11
+
12
+ /** Scalar query parameter transport supported by OpenAPI form encoding. */
13
+ export type QueryScalarTransport =
14
+ | { readonly kind: "string" }
15
+ | { readonly kind: "number" }
16
+ | { readonly kind: "integer" }
17
+ | { readonly kind: "boolean" }
18
+ | { readonly kind: "date-time" }
19
+ | { readonly kind: "date" };
20
+
21
+ /** Repeated scalar query parameter transport. */
22
+ export type QueryArrayTransport<
23
+ TItem extends QueryScalarTransport = QueryScalarTransport,
24
+ > = {
25
+ readonly kind: "array";
26
+ readonly item: TItem;
27
+ readonly empty: QueryEmptyBehavior;
28
+ };
29
+
30
+ /** Fields supported inside one flat OpenAPI deepObject parameter. */
31
+ export type QueryDeepObjectFields = Readonly<
32
+ Record<string, QueryScalarTransport>
33
+ >;
34
+
35
+ /** Flat OpenAPI deepObject query parameter transport. */
36
+ export type QueryDeepObjectTransport<
37
+ TFields extends QueryDeepObjectFields = QueryDeepObjectFields,
38
+ > = {
39
+ readonly kind: "deep-object";
40
+ readonly fields: TFields;
41
+ readonly empty: QueryEmptyBehavior;
42
+ };
43
+
44
+ /** Transport for one top-level query parameter. */
45
+ export type QueryFieldTransport =
46
+ | QueryScalarTransport
47
+ | QueryArrayTransport
48
+ | QueryDeepObjectTransport;
49
+
50
+ /** Query field declarations keyed by their HTTP parameter names. */
51
+ export type QueryTransportFields = Readonly<
52
+ Record<string, QueryFieldTransport>
53
+ >;
54
+
55
+ /**
56
+ * Deterministic HTTP transport for a contract query schema.
57
+ *
58
+ * The same declaration drives server decoding, typed-client encoding, and
59
+ * OpenAPI parameter serialization.
60
+ */
61
+ export type QueryTransport<
62
+ TFields extends QueryTransportFields = QueryTransportFields,
63
+ > = {
64
+ readonly kind: "query-transport";
65
+ readonly fields: TFields;
66
+ };
67
+
68
+ /** One deterministic query transport failure. */
69
+ export type QueryTransportIssue = {
70
+ readonly message: string;
71
+ readonly path: readonly (string | number)[];
72
+ };
73
+
74
+ /** Raised when an HTTP query cannot be encoded or decoded as declared. */
75
+ export class QueryTransportError extends Error {
76
+ readonly issues: readonly QueryTransportIssue[];
77
+
78
+ constructor(issue: QueryTransportIssue) {
79
+ super(issue.message);
80
+ this.name = "QueryTransportError";
81
+ this.issues = [issue];
82
+ }
83
+ }
84
+
85
+ /** Logical value encoded by one query field transport. */
86
+ export type QueryFieldTransportInput<TField extends QueryFieldTransport> =
87
+ TField extends { readonly kind: "string" | "date-time" }
88
+ ? string
89
+ : TField extends { readonly kind: "number" | "integer" }
90
+ ? number
91
+ : TField extends { readonly kind: "boolean" }
92
+ ? boolean
93
+ : TField extends { readonly kind: "date" }
94
+ ? Date
95
+ : TField extends QueryArrayTransport<infer TItem>
96
+ ? readonly QueryFieldTransportInput<TItem>[]
97
+ : TField extends QueryDeepObjectTransport<infer TFields>
98
+ ? {
99
+ [K in keyof TFields]?: QueryFieldTransportInput<TFields[K]>;
100
+ }
101
+ : never;
102
+
103
+ /** Logical values encoded by a query transport declaration. */
104
+ export type QueryTransportInput<TTransport extends QueryTransport> = {
105
+ [K in keyof TTransport["fields"]]?: QueryFieldTransportInput<
106
+ TTransport["fields"][K]
107
+ >;
108
+ };
109
+
110
+ type QueryKeys<T> = Extract<keyof T, string>;
111
+ type DeclaredQueryTransportFields<TTransport extends QueryTransport> =
112
+ TTransport extends QueryTransport<infer TFields> ? TFields : never;
113
+
114
+ type QueryFieldMatches<TInput, TField extends QueryFieldTransport> =
115
+ TField extends QueryArrayTransport<infer TItem>
116
+ ? Exclude<TInput, undefined> extends readonly (infer TEntry)[]
117
+ ? QueryFieldMatches<TEntry, TItem>
118
+ : false
119
+ : TField extends QueryDeepObjectTransport<infer TFields>
120
+ ? Exclude<TInput, undefined> extends object
121
+ ? Exclude<
122
+ QueryKeys<Exclude<TInput, undefined>>,
123
+ keyof TFields
124
+ > extends never
125
+ ? Exclude<
126
+ keyof TFields,
127
+ QueryKeys<Exclude<TInput, undefined>>
128
+ > extends never
129
+ ? false extends {
130
+ [K in keyof TFields]: K extends keyof Exclude<TInput, undefined>
131
+ ? QueryFieldMatches<Exclude<TInput, undefined>[K], TFields[K]>
132
+ : false;
133
+ }[keyof TFields]
134
+ ? false
135
+ : true
136
+ : false
137
+ : false
138
+ : false
139
+ : Exclude<TInput, undefined> extends QueryFieldTransportInput<TField>
140
+ ? true
141
+ : false;
142
+
143
+ type QueryTransportMatchesInput<
144
+ TInput,
145
+ TTransport extends QueryTransport,
146
+ > = unknown extends TInput
147
+ ? true
148
+ : TInput extends object
149
+ ? keyof DeclaredQueryTransportFields<TTransport> extends never
150
+ ? QueryKeys<TInput> extends never
151
+ ? true
152
+ : TInput[QueryKeys<TInput>] extends never
153
+ ? true
154
+ : false
155
+ : Exclude<
156
+ QueryKeys<TInput>,
157
+ keyof DeclaredQueryTransportFields<TTransport>
158
+ > extends never
159
+ ? Exclude<
160
+ keyof DeclaredQueryTransportFields<TTransport>,
161
+ QueryKeys<TInput>
162
+ > extends never
163
+ ? false extends {
164
+ [K in keyof DeclaredQueryTransportFields<TTransport>]: K extends keyof TInput
165
+ ? QueryFieldMatches<
166
+ TInput[K],
167
+ DeclaredQueryTransportFields<TTransport>[K]
168
+ >
169
+ : false;
170
+ }[keyof DeclaredQueryTransportFields<TTransport>]
171
+ ? false
172
+ : true
173
+ : false
174
+ : false
175
+ : false;
176
+
177
+ /**
178
+ * Type-level contract used by `.query(...)` to keep schema input fields and
179
+ * transport declarations aligned.
180
+ */
181
+ export type QueryTransportCompatibility<
182
+ TInput,
183
+ TTransport extends QueryTransport,
184
+ > =
185
+ QueryTransportMatchesInput<TInput, TTransport> extends true
186
+ ? unknown
187
+ : {
188
+ readonly __beignetQueryTransportMismatch: "Query transport fields and value kinds must match the Standard Schema input type.";
189
+ };
190
+
191
+ type EmptyOptions = {
192
+ /**
193
+ * Empty collections are omitted by default because standard OpenAPI query
194
+ * serialization cannot distinguish them from absent parameters. `preserve`
195
+ * opts into Beignet's versioned typed-client extension.
196
+ */
197
+ empty?: QueryEmptyBehavior;
198
+ };
199
+
200
+ /** Built-in, OpenAPI-inspectable query parameter transports. */
201
+ export const query = {
202
+ string(): QueryScalarTransport & { readonly kind: "string" } {
203
+ return Object.freeze({ kind: "string" });
204
+ },
205
+ number(): QueryScalarTransport & { readonly kind: "number" } {
206
+ return Object.freeze({ kind: "number" });
207
+ },
208
+ integer(): QueryScalarTransport & { readonly kind: "integer" } {
209
+ return Object.freeze({ kind: "integer" });
210
+ },
211
+ boolean(): QueryScalarTransport & { readonly kind: "boolean" } {
212
+ return Object.freeze({ kind: "boolean" });
213
+ },
214
+ dateTime(): QueryScalarTransport & { readonly kind: "date-time" } {
215
+ return Object.freeze({ kind: "date-time" });
216
+ },
217
+ date(): QueryScalarTransport & { readonly kind: "date" } {
218
+ return Object.freeze({ kind: "date" });
219
+ },
220
+ array<const TItem extends QueryScalarTransport>(
221
+ item: TItem,
222
+ options: EmptyOptions = {},
223
+ ): QueryArrayTransport<TItem> {
224
+ return Object.freeze({
225
+ kind: "array",
226
+ item,
227
+ empty: options.empty ?? "omit",
228
+ });
229
+ },
230
+ deepObject<const TFields extends QueryDeepObjectFields>(
231
+ fields: TFields,
232
+ options: EmptyOptions = {},
233
+ ): QueryDeepObjectTransport<TFields> {
234
+ assertFieldNames(fields, "deepObject");
235
+ return Object.freeze({
236
+ kind: "deep-object",
237
+ fields: copyFields(fields),
238
+ empty: options.empty ?? "omit",
239
+ });
240
+ },
241
+ } as const;
242
+
243
+ /** Define a reusable deterministic query transport. */
244
+ export function defineQueryTransport<
245
+ const TFields extends QueryTransportFields,
246
+ >(fields: TFields): QueryTransport<TFields> {
247
+ assertFieldNames(fields, "query");
248
+ return Object.freeze({
249
+ kind: "query-transport",
250
+ fields: copyFields(fields),
251
+ });
252
+ }
253
+
254
+ function copyFields<TFields extends QueryTransportFields>(
255
+ fields: TFields,
256
+ ): TFields {
257
+ return Object.freeze({ ...fields }) as TFields;
258
+ }
259
+
260
+ function assertFieldNames(
261
+ fields: Readonly<Record<string, unknown>>,
262
+ location: string,
263
+ ): void {
264
+ for (const name of Object.keys(fields)) {
265
+ if (
266
+ name.length === 0 ||
267
+ name.includes("[") ||
268
+ name.includes("]") ||
269
+ UNSAFE_QUERY_NAMES.has(name)
270
+ ) {
271
+ throw new Error(
272
+ `Invalid ${location} transport field "${name}". Query transport field names must be non-empty, must not contain brackets, and must not use prototype-related names.`,
273
+ );
274
+ }
275
+ }
276
+ }
277
+
278
+ function queryRecord(): Record<string, unknown> {
279
+ return Object.create(null) as Record<string, unknown>;
280
+ }
281
+
282
+ function issue(path: readonly (string | number)[], message: string): never {
283
+ throw new QueryTransportError({ path, message });
284
+ }
285
+
286
+ function isLeapYear(year: number): boolean {
287
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
288
+ }
289
+
290
+ function daysInMonth(year: number, month: number): number {
291
+ if (month === 2) return isLeapYear(year) ? 29 : 28;
292
+ return [4, 6, 9, 11].includes(month) ? 30 : 31;
293
+ }
294
+
295
+ function isDateTime(value: string): boolean {
296
+ const match = DATE_TIME_PATTERN.exec(value);
297
+ if (!match) return false;
298
+
299
+ const year = Number(match[1]);
300
+ const month = Number(match[2]);
301
+ const day = Number(match[3]);
302
+ const hour = Number(match[4]);
303
+ const minute = Number(match[5]);
304
+ const second = Number(match[6]);
305
+ const offsetHour = match[9] === undefined ? 0 : Number(match[9]);
306
+ const offsetMinute = match[10] === undefined ? 0 : Number(match[10]);
307
+ const componentsAreValid =
308
+ month >= 1 &&
309
+ month <= 12 &&
310
+ day >= 1 &&
311
+ day <= daysInMonth(year, month) &&
312
+ hour <= 23 &&
313
+ minute <= 59 &&
314
+ second <= 60 &&
315
+ offsetHour <= 23 &&
316
+ offsetMinute <= 59;
317
+ if (!componentsAreValid) return false;
318
+
319
+ if (second < 60) return Number.isFinite(Date.parse(value));
320
+
321
+ const precedingSecond = value.replace(
322
+ /^(\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:)60/,
323
+ (_match, prefix: string) => `${prefix}59`,
324
+ );
325
+ const parsed = Date.parse(precedingSecond);
326
+ if (!Number.isFinite(parsed)) return false;
327
+
328
+ const utc = new Date(parsed);
329
+ return (
330
+ utc.getUTCHours() === 23 &&
331
+ utc.getUTCMinutes() === 59 &&
332
+ utc.getUTCSeconds() === 59 &&
333
+ ((utc.getUTCMonth() === 5 && utc.getUTCDate() === 30) ||
334
+ (utc.getUTCMonth() === 11 && utc.getUTCDate() === 31))
335
+ );
336
+ }
337
+
338
+ function decodeScalar(
339
+ transport: QueryScalarTransport,
340
+ value: string,
341
+ path: readonly (string | number)[],
342
+ ): unknown {
343
+ switch (transport.kind) {
344
+ case "string":
345
+ return value;
346
+ case "number": {
347
+ if (!NUMBER_PATTERN.test(value)) {
348
+ return issue(path, "Expected a finite number query value.");
349
+ }
350
+ const decoded = Number(value);
351
+ return Number.isFinite(decoded)
352
+ ? decoded
353
+ : issue(path, "Expected a finite number query value.");
354
+ }
355
+ case "integer": {
356
+ if (!NUMBER_PATTERN.test(value)) {
357
+ return issue(path, "Expected an integer query value.");
358
+ }
359
+ const decoded = Number(value);
360
+ return Number.isSafeInteger(decoded)
361
+ ? decoded
362
+ : issue(path, "Expected a safe integer query value.");
363
+ }
364
+ case "boolean":
365
+ if (value === "true") return true;
366
+ if (value === "false") return false;
367
+ return issue(path, 'Expected a boolean query value ("true" or "false").');
368
+ case "date-time":
369
+ return isDateTime(value)
370
+ ? value
371
+ : issue(path, "Expected an RFC 3339 date-time query value.");
372
+ case "date": {
373
+ if (!isDateTime(value)) {
374
+ return issue(path, "Expected an RFC 3339 date-time query value.");
375
+ }
376
+ const decoded = new Date(value);
377
+ return Number.isFinite(decoded.getTime())
378
+ ? decoded
379
+ : issue(
380
+ path,
381
+ "Expected an RFC 3339 date-time representable by JavaScript Date.",
382
+ );
383
+ }
384
+ }
385
+ }
386
+
387
+ function encodeScalar(
388
+ transport: QueryScalarTransport,
389
+ value: unknown,
390
+ path: readonly (string | number)[],
391
+ ): string {
392
+ switch (transport.kind) {
393
+ case "string":
394
+ return typeof value === "string"
395
+ ? value
396
+ : issue(path, "Expected a string query value.");
397
+ case "number":
398
+ return typeof value === "number" && Number.isFinite(value)
399
+ ? String(value)
400
+ : issue(path, "Expected a finite number query value.");
401
+ case "integer":
402
+ return typeof value === "number" && Number.isSafeInteger(value)
403
+ ? String(value)
404
+ : issue(path, "Expected a safe integer query value.");
405
+ case "boolean":
406
+ return typeof value === "boolean"
407
+ ? String(value)
408
+ : issue(path, "Expected a boolean query value.");
409
+ case "date-time":
410
+ return typeof value === "string" && isDateTime(value)
411
+ ? value
412
+ : issue(path, "Expected an RFC 3339 date-time query value.");
413
+ case "date":
414
+ return value instanceof Date && Number.isFinite(value.getTime())
415
+ ? value.toISOString()
416
+ : issue(path, "Expected a valid Date query value.");
417
+ }
418
+ }
419
+
420
+ function oneValue(
421
+ values: readonly string[],
422
+ path: readonly (string | number)[],
423
+ ): string {
424
+ if (values.length !== 1) {
425
+ return issue(path, "Expected exactly one query value.");
426
+ }
427
+ return values[0] as string;
428
+ }
429
+
430
+ function rawQuery(searchParams: URLSearchParams): Record<string, unknown> {
431
+ const result = queryRecord();
432
+ for (const name of new Set(searchParams.keys())) {
433
+ const values = searchParams.getAll(name);
434
+ result[name] = values.length === 1 ? values[0] : values;
435
+ }
436
+ return result;
437
+ }
438
+
439
+ function encodeArrayItem(
440
+ field: QueryArrayTransport,
441
+ value: unknown,
442
+ path: readonly (string | number)[],
443
+ ): string {
444
+ const encoded = encodeScalar(field.item, value, path);
445
+ if (
446
+ field.empty === "preserve" &&
447
+ field.item.kind === "string" &&
448
+ encoded.startsWith(QUERY_EXTENSION_PREFIX)
449
+ ) {
450
+ return `${QUERY_EXTENSION_PREFIX}${JSON.stringify({
451
+ kind: "literal",
452
+ value: encoded,
453
+ })}`;
454
+ }
455
+ return encoded;
456
+ }
457
+
458
+ function decodeArrayItem(
459
+ field: QueryArrayTransport,
460
+ value: string,
461
+ path: readonly (string | number)[],
462
+ ): unknown {
463
+ if (
464
+ field.empty === "preserve" &&
465
+ field.item.kind === "string" &&
466
+ value.startsWith(QUERY_EXTENSION_PREFIX)
467
+ ) {
468
+ try {
469
+ const extension = JSON.parse(
470
+ value.slice(QUERY_EXTENSION_PREFIX.length),
471
+ ) as unknown;
472
+ if (
473
+ extension &&
474
+ typeof extension === "object" &&
475
+ "kind" in extension &&
476
+ extension.kind === "literal" &&
477
+ "value" in extension &&
478
+ typeof extension.value === "string"
479
+ ) {
480
+ return extension.value;
481
+ }
482
+ } catch {
483
+ // Unknown extension-looking strings remain ordinary string values.
484
+ }
485
+ }
486
+ return decodeScalar(field.item, value, path);
487
+ }
488
+
489
+ /** Decode URL query values exactly once according to a contract transport. */
490
+ export function decodeQueryTransport(
491
+ transport: QueryTransport,
492
+ searchParams: URLSearchParams,
493
+ ): Record<string, unknown> {
494
+ const decoded = rawQuery(searchParams);
495
+
496
+ for (const [name, field] of Object.entries(transport.fields)) {
497
+ const values = searchParams.getAll(name);
498
+ if (field.kind === "deep-object") {
499
+ const direct = values.length > 0;
500
+ const prefix = `${name}[`;
501
+ const nestedNames = [...new Set(searchParams.keys())].filter((key) =>
502
+ key.startsWith(prefix),
503
+ );
504
+ if (direct && nestedNames.length > 0) {
505
+ issue([name], "Query parameter conflicts with its deepObject fields.");
506
+ }
507
+ if (direct) {
508
+ if (
509
+ field.empty === "preserve" &&
510
+ values.length === 1 &&
511
+ values[0] === EMPTY_OBJECT_EXTENSION
512
+ ) {
513
+ decoded[name] = {};
514
+ continue;
515
+ }
516
+ issue([name], "Expected a deepObject query parameter.");
517
+ }
518
+ if (nestedNames.length === 0) continue;
519
+
520
+ const object = queryRecord();
521
+ for (const nestedName of nestedNames) {
522
+ const suffix = nestedName.slice(prefix.length);
523
+ if (!suffix.endsWith("]") || suffix.slice(0, -1).includes("[")) {
524
+ issue([name], "Expected a flat deepObject query parameter.");
525
+ }
526
+ const childName = suffix.slice(0, -1);
527
+ const child = field.fields[childName];
528
+ if (!child) {
529
+ issue(
530
+ [name, childName],
531
+ "Query transport does not declare this deepObject field.",
532
+ );
533
+ }
534
+ object[childName] = decodeScalar(
535
+ child,
536
+ oneValue(searchParams.getAll(nestedName), [name, childName]),
537
+ [name, childName],
538
+ );
539
+ delete decoded[nestedName];
540
+ }
541
+ decoded[name] = object;
542
+ continue;
543
+ }
544
+
545
+ if (values.length === 0) continue;
546
+ if (field.kind === "array") {
547
+ if (
548
+ field.empty === "preserve" &&
549
+ values.length === 1 &&
550
+ values[0] === EMPTY_ARRAY_EXTENSION
551
+ ) {
552
+ decoded[name] = [];
553
+ } else {
554
+ decoded[name] = values.map((value, index) =>
555
+ decodeArrayItem(field, value, [name, index]),
556
+ );
557
+ }
558
+ continue;
559
+ }
560
+ decoded[name] = decodeScalar(field, oneValue(values, [name]), [name]);
561
+ }
562
+
563
+ return Object.fromEntries(Object.entries(decoded));
564
+ }
565
+
566
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
567
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
568
+ return false;
569
+ }
570
+ const prototype = Object.getPrototypeOf(value);
571
+ return prototype === Object.prototype || prototype === null;
572
+ }
573
+
574
+ /** Encode typed query input according to the same contract transport. */
575
+ export function encodeQueryTransport(
576
+ transport: QueryTransport,
577
+ value: unknown,
578
+ ): URLSearchParams {
579
+ if (!isPlainObject(value)) {
580
+ issue([], "Expected query input to be an object.");
581
+ }
582
+
583
+ const params = new URLSearchParams();
584
+ for (const name of Object.keys(value)) {
585
+ if (value[name] !== undefined && !transport.fields[name]) {
586
+ issue([name], "Query transport does not declare this field.");
587
+ }
588
+ }
589
+
590
+ for (const [name, field] of Object.entries(transport.fields)) {
591
+ const fieldValue = value[name];
592
+ if (fieldValue === undefined) continue;
593
+ if (fieldValue === null) {
594
+ issue(
595
+ [name],
596
+ "Null query values are not representable; omit the parameter instead.",
597
+ );
598
+ }
599
+
600
+ if (field.kind === "array") {
601
+ if (!Array.isArray(fieldValue)) {
602
+ issue([name], "Expected an array query value.");
603
+ }
604
+ if (fieldValue.length === 0) {
605
+ if (field.empty === "preserve") {
606
+ params.append(name, EMPTY_ARRAY_EXTENSION);
607
+ }
608
+ continue;
609
+ }
610
+ fieldValue.forEach((entry, index) => {
611
+ params.append(name, encodeArrayItem(field, entry, [name, index]));
612
+ });
613
+ continue;
614
+ }
615
+
616
+ if (field.kind === "deep-object") {
617
+ if (!isPlainObject(fieldValue)) {
618
+ issue([name], "Expected an object query value.");
619
+ }
620
+ for (const childName of Object.keys(fieldValue)) {
621
+ if (fieldValue[childName] !== undefined && !field.fields[childName]) {
622
+ issue(
623
+ [name, childName],
624
+ "Query transport does not declare this deepObject field.",
625
+ );
626
+ }
627
+ }
628
+ const entries = Object.entries(fieldValue).filter(
629
+ ([, entry]) => entry !== undefined,
630
+ );
631
+ if (entries.length === 0) {
632
+ if (field.empty === "preserve") {
633
+ params.append(name, EMPTY_OBJECT_EXTENSION);
634
+ }
635
+ continue;
636
+ }
637
+ for (const [childName, entry] of entries) {
638
+ const child = field.fields[childName];
639
+ if (!child) continue;
640
+ params.append(
641
+ `${name}[${childName}]`,
642
+ encodeScalar(child, entry, [name, childName]),
643
+ );
644
+ }
645
+ continue;
646
+ }
647
+
648
+ params.append(name, encodeScalar(field, fieldValue, [name]));
649
+ }
650
+
651
+ return params;
652
+ }
653
+
654
+ /** OpenAPI schema keywords implied by a query field transport. */
655
+ export function queryTransportSchema(
656
+ field: QueryFieldTransport,
657
+ ): Record<string, unknown> {
658
+ switch (field.kind) {
659
+ case "string":
660
+ return { type: "string" };
661
+ case "number":
662
+ return { type: "number" };
663
+ case "integer":
664
+ return {
665
+ type: "integer",
666
+ minimum: Number.MIN_SAFE_INTEGER,
667
+ maximum: Number.MAX_SAFE_INTEGER,
668
+ };
669
+ case "boolean":
670
+ return { type: "boolean" };
671
+ case "date-time":
672
+ case "date":
673
+ return { type: "string", format: "date-time" };
674
+ case "array":
675
+ return {
676
+ type: "array",
677
+ items: queryTransportSchema(field.item),
678
+ ...(field.empty === "preserve"
679
+ ? { "x-beignet-empty-query": "v1" }
680
+ : {}),
681
+ };
682
+ case "deep-object":
683
+ return {
684
+ type: "object",
685
+ properties: Object.fromEntries(
686
+ Object.entries(field.fields).map(([name, child]) => [
687
+ name,
688
+ queryTransportSchema(child),
689
+ ]),
690
+ ),
691
+ additionalProperties: false,
692
+ ...(field.empty === "preserve"
693
+ ? { "x-beignet-empty-query": "v1" }
694
+ : {}),
695
+ };
696
+ }
697
+ }