bazis 0.98.0 → 0.98.2

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.
@@ -355,7 +355,7 @@ function listQueryFile(n: ModuleNaming): string {
355
355
  return `import { Filterable, ListOptions, ListRequest, Sortable } from "bazis/core/http";
356
356
  import type { ${n.entity} } from "../../model/${n.module}.model";
357
357
 
358
- @ListOptions({ defaultSize: 20, maxSize: 100 })
358
+ @ListOptions({ defaultSize: 20, maxSize: 100, defaultSort: "-createdAt" })
359
359
  export class ${n.module}ListQuery extends ListRequest<${n.entity}> {
360
360
  @Sortable()
361
361
  @Filterable("eq", "contains", "startsWith")
@@ -24,6 +24,21 @@ function bindValue(binding: ParameterBinding, raw: string | undefined, kind: str
24
24
  return binding.type && binding.type !== "string" ? convertOr400(raw, binding.type, binding.name ?? "?") : raw;
25
25
  }
26
26
 
27
+ /**
28
+ * `?tag=a&tag=b` → `["a", "b"]`, each value converted by the element type.
29
+ * Without the parameter: the default, `undefined` for `tag?: string[]`, or `[]`.
30
+ */
31
+ function bindArray(binding: ParameterBinding, ctx: HttpContext): unknown {
32
+ const raw = ctx.url.searchParams.getAll(binding.name ?? "");
33
+ if (raw.length === 0) {
34
+ if (binding.defaultValue !== undefined) return binding.defaultValue;
35
+ return binding.optional ? undefined : [];
36
+ }
37
+ return binding.type && binding.type !== "string"
38
+ ? raw.map((value) => convertOr400(value, binding.type!, binding.name ?? "?"))
39
+ : raw;
40
+ }
41
+
27
42
  /**
28
43
  * Resolves generated binding descriptors into the action's argument list.
29
44
  * All conversions fail with 400 (never 500); body parse errors and model
@@ -52,7 +67,7 @@ export async function bindArguments(
52
67
  break;
53
68
  }
54
69
  case "query":
55
- args[index] = bindValue(binding, ctx.query(binding.name ?? ""), "query");
70
+ args[index] = binding.array ? bindArray(binding, ctx) : bindValue(binding, ctx.query(binding.name ?? ""), "query");
56
71
  break;
57
72
  case "body": {
58
73
  const contentType = ctx.header("content-type") ?? "";
@@ -55,7 +55,7 @@ function toBinding(spec: GeneratedBindingSpec, controllerName: string, methodNam
55
55
  case "route":
56
56
  return { source: "route", name: spec.name, type: spec.type, optional: spec.optional };
57
57
  case "query":
58
- return { source: "query", name: spec.name, type: spec.type, optional: spec.optional };
58
+ return { source: "query", name: spec.name, type: spec.type, optional: spec.optional, array: spec.array };
59
59
  case "context":
60
60
  return { source: "context" };
61
61
  case "request":
@@ -11,6 +11,8 @@ export interface ParameterBinding {
11
11
  readonly type?: ValueType;
12
12
  readonly defaultValue?: unknown;
13
13
  readonly optional?: boolean;
14
+ /** Query array: all values of the name (`?tag=a&tag=b`), each converted by `type`. */
15
+ readonly array?: boolean;
14
16
  /** Body model class (instantiated, sanitized and validated). */
15
17
  readonly model?: Class<object>;
16
18
  /** Whitelist/limits for the class-based list binding (JSON:API list query). */
@@ -13,6 +13,8 @@ export interface GeneratedBindingSpec {
13
13
  readonly type?: "int" | "number" | "bool" | "string";
14
14
  /** The parameter is declared with `?` or a default value. */
15
15
  readonly optional?: boolean;
16
+ /** A query array (`tag: string[]`): every `?tag=` value, each converted by `type`. */
17
+ readonly array?: boolean;
16
18
  /** DTO class name (resolved through the generated target; `@RequestModel()` classes are the fallback). */
17
19
  readonly model?: string;
18
20
  }
@@ -173,8 +173,8 @@ function collectParameters(
173
173
  parameters.push({
174
174
  name: binding.name,
175
175
  in: "query",
176
- required: binding.optional !== true && binding.defaultValue === undefined,
177
- schema: schemaForValueType(binding.type),
176
+ required: binding.array !== true && binding.optional !== true && binding.defaultValue === undefined,
177
+ schema: binding.array ? { type: "array", items: schemaForValueType(binding.type) } : schemaForValueType(binding.type),
178
178
  });
179
179
  }
180
180
  break;
@@ -17,6 +17,8 @@ export interface RouteAction {
17
17
 
18
18
  interface RouteEntry {
19
19
  readonly action: RouteAction;
20
+ /** The registered path, for diagnostics: `/tasks/:id`. */
21
+ readonly template: string;
20
22
  /** Names belong to the selected declaration, never to shared tree edges. */
21
23
  readonly bindings: readonly { readonly name: string; readonly index: number }[];
22
24
  }
@@ -58,6 +60,7 @@ export class Router {
58
60
  register(segments: readonly TemplateSegment[], httpMethod: string, versionKey: string, action: RouteAction): void {
59
61
  const entry: RouteEntry = {
60
62
  action,
63
+ template: renderTemplate(segments),
61
64
  bindings: segments.flatMap((segment, index) => segment.kind === "static" ? [] : [{ name: segment.name, index }]),
62
65
  };
63
66
  let node = this.root;
@@ -101,8 +104,11 @@ export class Router {
101
104
  leaf.set(httpMethod, versions);
102
105
  }
103
106
  if (versions.has(versionKey)) {
107
+ const existing = versions.get(versionKey)!;
108
+ const version = versionKey ? ` (version ${versionKey})` : "";
104
109
  throw new HttpSetupError(
105
- `Duplicate route: ${httpMethod} (version "${versionKey || "-"}") already mapped to ${versions.get(versionKey)!.action.name}, cannot map ${entry.action.name}.`,
110
+ `Duplicate route: ${httpMethod} ${entry.template}${version} is mapped to both ${existing.action.name} (${existing.template}) and ${entry.action.name}. `
111
+ + "Routes that differ only in parameter names are the same route; change one of the paths.",
106
112
  );
107
113
  }
108
114
  versions.set(versionKey, entry);
@@ -199,3 +205,10 @@ function constraintPriority(constraint: SegmentConstraint | undefined): number {
199
205
  return 0;
200
206
  }
201
207
  }
208
+
209
+ /** `/tasks/:id(int)/files/*path` from parsed segments. */
210
+ function renderTemplate(segments: readonly TemplateSegment[]): string {
211
+ return `/${segments.map((segment) => segment.kind === "static" ? segment.value
212
+ : segment.kind === "param" ? `:${segment.name}${segment.constraint ? `(${segment.constraint.name})` : ""}`
213
+ : `*${segment.name}`).join("/")}`;
214
+ }
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
232
232
  if (method === "account/updated") this.modelCache = undefined;
233
233
  }, () => { this.clearLogin(); this.modelCache = undefined; });
234
234
  const initialized = await client.request("initialize", {
235
- clientInfo: { name: "bazis", title: "Bazis", version: "0.98.0" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.98.2" }, capabilities: { experimentalApi: true },
236
236
  }, 15_000, signal);
237
237
  if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
238
238
  client.notify("initialized");
@@ -178,6 +178,7 @@ interface HttpBindingSpec {
178
178
  type?: "int" | "number" | "bool" | "string";
179
179
  optional?: boolean;
180
180
  model?: string;
181
+ array?: boolean;
181
182
  }
182
183
  let httpBindings: Record<string, Record<string, HttpBindingSpec[]>> = {};
183
184
  interface GrpcRequestBinding {
@@ -1030,6 +1031,16 @@ function readConstructorTypeNames(node: ts.ClassDeclaration): ConstructorDepende
1030
1031
  }
1031
1032
 
1032
1033
  /** Resolve imported type aliases/re-exports; runtime named binding stays module-scoped. */
1034
+ /** The element keyword of an array type: `string[]`, `Array<string>`, `readonly string[]` -> "string". */
1035
+ function arrayElementKeyword(node: ts.TypeNode): string | undefined {
1036
+ if (ts.isTypeOperatorNode(node) && node.operator === ts.SyntaxKind.ReadonlyKeyword) return arrayElementKeyword(node.type);
1037
+ if (ts.isArrayTypeNode(node)) return node.elementType.getText().trim();
1038
+ if (ts.isTypeReferenceNode(node) && ["Array", "ReadonlyArray"].includes(node.typeName.getText()) && node.typeArguments?.length === 1) {
1039
+ return node.typeArguments[0]!.getText().trim();
1040
+ }
1041
+ return undefined;
1042
+ }
1043
+
1033
1044
  function getDependencyTypeName(node: ts.TypeNode): string | undefined {
1034
1045
  if (!ts.isTypeReferenceNode(node)) return undefined;
1035
1046
  const symbol = checker.getSymbolAtLocation(node.typeName);
@@ -1425,6 +1436,13 @@ function collectControllerBindings(node: ts.ClassDeclaration, controllerName: st
1425
1436
  specs.push({ source: "route", name: paramName, type: type === "string" ? undefined : type, optional });
1426
1437
  continue;
1427
1438
  }
1439
+ // `tag: string[]`, `Array<number>`, `readonly boolean[]` -> every ?tag= value.
1440
+ const elementKeyword = typeNode ? arrayElementKeyword(typeNode) : undefined;
1441
+ const elementType = elementKeyword !== undefined ? PRIMITIVE_QUERY_TYPES[elementKeyword] : undefined;
1442
+ if (elementType !== undefined) {
1443
+ specs.push({ source: "query", name: paramName, type: elementType === "string" ? undefined : elementType, optional, array: true });
1444
+ continue;
1445
+ }
1428
1446
  const primitiveType =
1429
1447
  keywordType !== undefined ? PRIMITIVE_QUERY_TYPES[keywordType] : inferTypeFromInitializer(param.initializer);
1430
1448
  if (primitiveType !== undefined) {
@@ -18,6 +18,7 @@ export interface ListSchema {
18
18
  readonly fields: Record<string, FieldSchema>;
19
19
  page?: PageOptions;
20
20
  include?: readonly string[];
21
+ defaultSort?: string;
21
22
  }
22
23
 
23
24
  interface SchemaMetadata {
@@ -32,6 +33,11 @@ export interface ListOptionsConfig {
32
33
  readonly maxSize?: number;
33
34
  /** Allowed include paths (`?include=`). */
34
35
  readonly include?: readonly string[];
36
+ /**
37
+ * Sorting when the request has no `sort`: `"-createdAt"`, `"-createdAt,name"`.
38
+ * Every field must be `@Sortable()`. The primary key still breaks ties.
39
+ */
40
+ readonly defaultSort?: string;
35
41
  }
36
42
 
37
43
  /**
@@ -43,7 +49,7 @@ function ensureOwnSchema(metadata: SchemaMetadata): ListSchema {
43
49
  if (!Object.prototype.hasOwnProperty.call(metadata, LIST_SCHEMA)) {
44
50
  const inherited = metadata[LIST_SCHEMA];
45
51
  metadata[LIST_SCHEMA] = inherited
46
- ? { fields: cloneFields(inherited.fields), page: inherited.page, include: inherited.include }
52
+ ? { fields: cloneFields(inherited.fields), page: inherited.page, include: inherited.include, defaultSort: inherited.defaultSort }
47
53
  : { fields: {} };
48
54
  }
49
55
  return metadata[LIST_SCHEMA]!;
@@ -124,6 +130,16 @@ export function ListOptions(config: ListOptionsConfig) {
124
130
  if (config.include !== undefined) {
125
131
  schema.include = config.include;
126
132
  }
133
+ if (config.defaultSort !== undefined) {
134
+ // Field decorators have already run: check the declared fields now, at import time.
135
+ for (const token of config.defaultSort.split(",").map((part) => part.trim()).filter(Boolean)) {
136
+ const field = token.replace(/^[-+]/, "");
137
+ if (schema.fields[field]?.sortable !== true) {
138
+ throw new Error(`@ListOptions on ${String(context.name)}: defaultSort field "${field}" is not @Sortable().`);
139
+ }
140
+ }
141
+ schema.defaultSort = config.defaultSort;
142
+ }
127
143
  };
128
144
  }
129
145
 
@@ -157,5 +173,5 @@ export function optionsFromSchema(ctor: unknown): ListQueryOptions {
157
173
  filter[name] = field.filterOps;
158
174
  }
159
175
  }
160
- return { sort, filter, include: schema.include, page: schema.page };
176
+ return { sort, filter, include: schema.include, page: schema.page, defaultSort: schema.defaultSort };
161
177
  }
@@ -38,6 +38,12 @@ export interface BuildListDocumentOptions {
38
38
  * `links` are not built.
39
39
  */
40
40
  readonly basePath?: string;
41
+ /**
42
+ * JSON:API resource type for sparse fieldsets: `?fields[tasks]=name` keeps
43
+ * only `id` and `name` in every item. Default: the last segment of
44
+ * `basePath` (`"/api/tasks"` → `"tasks"`).
45
+ */
46
+ readonly type?: string;
41
47
  }
42
48
 
43
49
  /**
@@ -59,10 +65,29 @@ export function buildListDocument<T>(
59
65
  const pageCount = size > 0 ? Math.max(1, Math.ceil(total / size)) : 1;
60
66
  const meta: ListMeta = { total, page: query.page.number, size, pageCount };
61
67
 
68
+ const data = sparseFieldset(items, query, options.type ?? lastSegment(options.basePath));
62
69
  if (options.basePath === undefined) {
63
- return { data: items, meta };
70
+ return { data, meta };
71
+ }
72
+ return { data, meta, links: buildLinks(query, options.basePath, meta) };
73
+ }
74
+
75
+ /** Items reduced to `id` and the fields requested with `fields[type]`; unchanged without it. */
76
+ function sparseFieldset<T>(items: readonly T[], query: ListQuery, type: string | undefined): readonly T[] {
77
+ const fields = type === undefined ? undefined : query.fields[type];
78
+ if (fields === undefined || fields.length === 0) {
79
+ return items;
64
80
  }
65
- return { data: items, meta, links: buildLinks(query, options.basePath, meta) };
81
+ const keep = new Set(["id", ...fields]);
82
+ return items.map((item) => {
83
+ if (item === null || typeof item !== "object") return item;
84
+ return Object.fromEntries(Object.entries(item as Record<string, unknown>).filter(([key]) => keep.has(key))) as T;
85
+ });
86
+ }
87
+
88
+ function lastSegment(basePath: string | undefined): string | undefined {
89
+ const segment = basePath?.split("?")[0]?.split("/").filter(Boolean).at(-1);
90
+ return segment === undefined || segment === "" ? undefined : segment;
66
91
  }
67
92
 
68
93
  /**
@@ -77,7 +77,9 @@ function parseSort(
77
77
  problems: ListQueryProblem[],
78
78
  ): SortField[] {
79
79
  const result: SortField[] = [];
80
- for (const token of splitCsv(params.getAll("sort"))) {
80
+ const requested = splitCsv(params.getAll("sort"));
81
+ const tokens = requested.length === 0 && options.defaultSort !== undefined ? splitCsv([options.defaultSort]) : requested;
82
+ for (const token of tokens) {
81
83
  const dir: SortDirection = token.startsWith("-") ? "desc" : "asc";
82
84
  const field = token.replace(/^[-+]/, "");
83
85
  if (field === "") {
@@ -122,4 +122,6 @@ export interface ListQueryOptions {
122
122
  readonly include?: readonly string[];
123
123
  /** Paging limits. */
124
124
  readonly page?: PageOptions;
125
+ /** Sorting when the request has no `sort`, in the same syntax: `"-createdAt,name"`. */
126
+ readonly defaultSort?: string;
125
127
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.98.0",
3
+ "version": "0.98.2",
4
4
  "description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Alexander Alenin",