bazis 0.98.1 → 0.98.3

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")
@@ -11,6 +11,20 @@ export interface BindingDefaults {
11
11
  consumes?: string;
12
12
  }
13
13
 
14
+ const BODILESS_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
15
+
16
+ /**
17
+ * `consumes` of a route applies to every request with a body, however the
18
+ * action reads it: a body model, `ctx.formData()`, `ctx.text()` or nothing.
19
+ */
20
+ export function assertConsumes(ctx: HttpContext, consumes: string | undefined): void {
21
+ if (consumes === undefined || BODILESS_METHODS.has(ctx.method)) return;
22
+ const contentType = ctx.header("content-type") ?? "";
23
+ if (!contentType.toLowerCase().startsWith(consumes.toLowerCase())) {
24
+ throw new UnsupportedMediaTypeError(consumes);
25
+ }
26
+ }
27
+
14
28
  function bindValue(binding: ParameterBinding, raw: string | undefined, kind: string): unknown {
15
29
  if (raw === undefined) {
16
30
  if (binding.defaultValue !== undefined) {
@@ -1,5 +1,5 @@
1
1
  import type { Class, Token } from "../../di";
2
- import { bindArguments } from "../Binding/ParameterBinder";
2
+ import { assertConsumes, bindArguments } from "../Binding/ParameterBinder";
3
3
  import { resolveGeneratedBindings } from "../Binding/autoBindings";
4
4
  import { generatedSourcesAreStale } from "../../generatedFingerprint";
5
5
  import { controllerMetaOf, type ActionMeta, type ControllerMeta } from "../Decorators/metadata";
@@ -139,6 +139,7 @@ export class RouterBuilder {
139
139
 
140
140
  const boundary: HttpMiddleware = async (ctx, next) => {
141
141
  try {
142
+ assertConsumes(ctx, bindingDefaults.consumes);
142
143
  const args = bindings ? await bindArguments(bindings, ctx, bindingDefaults) : [ctx];
143
144
  for (const filter of filters) {
144
145
  await filter.before?.(ctx);
@@ -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.1" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.98.3" }, 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");
@@ -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.1",
3
+ "version": "0.98.3",
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",