@antelopejs/interface-dms-builder 0.1.1 → 0.1.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.
package/dist/catalog.d.ts CHANGED
@@ -13,7 +13,7 @@ export interface DynamicSlots {
13
13
  idKey: string;
14
14
  labelKey?: string;
15
15
  }
16
- export type OptionWidget = "text" | "textarea" | "number" | "range" | "switch" | "select" | "segmented" | "icon" | "color" | "url" | "resource" | "field" | "dataType" | "query" | "permission" | "json" | "block";
16
+ export type OptionWidget = "text" | "textarea" | "number" | "range" | "switch" | "select" | "segmented" | "icon" | "color" | "url" | "resource" | "field" | "dataType" | "query" | "dataSource" | "permission" | "json" | "block";
17
17
  export type FieldAspect = "listable" | "searchable" | "sortable" | "filterable";
18
18
  export interface OptionUi {
19
19
  label?: string;
@@ -27,8 +27,15 @@ export interface OptionUi {
27
27
  step?: number;
28
28
  scope?: string;
29
29
  blockTypes?: string[];
30
+ supplies?: string[];
30
31
  fieldAspect?: FieldAspect | FieldAspect[];
31
32
  flatten?: boolean;
33
+ valueLabels?: Record<string, string>;
34
+ derivedFrom?: string;
35
+ optIn?: string;
36
+ typedBy?: string;
37
+ advanced?: boolean;
38
+ initial?: unknown;
32
39
  }
33
40
  export type OptionType = "string" | "number" | "boolean" | "object" | "array" | "record" | "union" | "unknown";
34
41
  export interface OptionSchema {
@@ -41,6 +48,7 @@ export interface OptionSchema {
41
48
  properties?: Record<string, OptionSchema>;
42
49
  items?: OptionSchema;
43
50
  values?: OptionSchema;
51
+ keys?: OptionSchema;
44
52
  oneOf?: OptionSchema[];
45
53
  discriminator?: string;
46
54
  prefixItems?: OptionSchema[];
@@ -49,6 +57,7 @@ export interface OptionSchema {
49
57
  ui?: OptionUi;
50
58
  "x-dataType"?: boolean;
51
59
  "x-component"?: boolean;
60
+ "x-controller"?: boolean;
52
61
  [key: string]: unknown;
53
62
  }
54
63
  export type ConfigSchema = Record<string, OptionSchema>;
@@ -85,3 +94,16 @@ export interface BlockCatalog {
85
94
  reservedFieldNames: string[];
86
95
  generatedAt: string;
87
96
  }
97
+ export interface DataSourceDescriptor {
98
+ id: string;
99
+ title: string;
100
+ description?: string;
101
+ responseShape: string;
102
+ path: string;
103
+ method?: string;
104
+ params?: ConfigSchema;
105
+ period?: {
106
+ from: string;
107
+ to: string;
108
+ };
109
+ }
package/dist/drafts.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { BlockPath } from "./addressing";
2
+ import type { AddQueryInput, QueryPreview } from "./queries";
2
3
  import type { ResourceRef } from "./resources";
3
4
  import type { MutationOpts, OpResult } from "./results";
4
5
  import type { EditablePageMeta } from "./structure";
@@ -15,6 +16,7 @@ export interface BlockDraft {
15
16
  export interface PageDraft {
16
17
  page?: Partial<EditablePageMeta>;
17
18
  blocks: BlockDraft[];
19
+ queries?: AddQueryInput[];
18
20
  }
19
21
  export interface ComponentPreview {
20
22
  componentName: string;
@@ -35,3 +37,9 @@ export declare const SetPageBlocks: (page: string, draft: PageDraft, opts?: Muta
35
37
  version: string;
36
38
  }>>;
37
39
  export declare const PreviewLayout: (page: string, draft: PageDraft) => Promise<OpResult<PageLayoutPreview>>;
40
+ export declare const PreviewQuery: (request: QueryPreviewRequest) => Promise<OpResult<QueryPreview>>;
41
+ export interface QueryPreviewRequest {
42
+ query: AddQueryInput;
43
+ args?: Record<string, unknown>;
44
+ tenant?: string;
45
+ }
package/dist/drafts.js CHANGED
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PreviewLayout = exports.SetPageBlocks = void 0;
3
+ exports.PreviewQuery = exports.PreviewLayout = exports.SetPageBlocks = void 0;
4
4
  const interface_core_1 = require("@antelopejs/interface-core");
5
5
  exports.SetPageBlocks = (0, interface_core_1.InterfaceFunction)();
6
6
  exports.PreviewLayout = (0, interface_core_1.InterfaceFunction)();
7
+ exports.PreviewQuery = (0, interface_core_1.InterfaceFunction)();
7
8
  //# sourceMappingURL=drafts.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"drafts.js","sourceRoot":"","sources":["../src/drafts.ts"],"names":[],"mappings":";;;AAAA,+DAA+D;AAwElD,QAAA,aAAa,GACxB,IAAA,kCAAiB,GAMd,CAAC;AAOO,QAAA,aAAa,GACxB,IAAA,kCAAiB,GAEd,CAAC"}
1
+ {"version":3,"file":"drafts.js","sourceRoot":"","sources":["../src/drafts.ts"],"names":[],"mappings":";;;AAAA,+DAA+D;AAmFlD,QAAA,aAAa,GACxB,IAAA,kCAAiB,GAMd,CAAC;AAOO,QAAA,aAAa,GACxB,IAAA,kCAAiB,GAEd,CAAC;AAUO,QAAA,YAAY,GACvB,IAAA,kCAAiB,GAEd,CAAC"}
@@ -1,5 +1,5 @@
1
1
  import type { BlockPath, CategoryRef, PageRef } from "./addressing";
2
- import type { BlockCatalog } from "./catalog";
2
+ import type { BlockCatalog, DataSourceDescriptor } from "./catalog";
3
3
  import type { ResourceRef } from "./resources";
4
4
  import type { MutationOpts, OpResult } from "./results";
5
5
  import type { CategorySummary, EditableCategoryMeta, EditablePageMeta, PagePermission, PageStructure, PageSummary } from "./structure";
@@ -46,6 +46,7 @@ export interface ConfigureBlockOpts extends MutationOpts {
46
46
  export declare const ListPages: () => Promise<PageSummary[]>;
47
47
  export declare const ListCategories: () => Promise<CategorySummary[]>;
48
48
  export declare const GetCatalog: () => Promise<BlockCatalog>;
49
+ export declare const ListDataSources: (responseShape?: string | undefined) => Promise<DataSourceDescriptor[]>;
49
50
  export declare const GetPageStructure: (ref: string) => Promise<OpResult<PageStructure>>;
50
51
  export declare const RefreshSourceIndex: (scope?: RefreshScope | undefined) => Promise<void>;
51
52
  export declare const CreatePage: (input: CreatePageInput) => Promise<OpResult<{
@@ -1,10 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RemoveBlock = exports.MoveBlock = exports.ConfigureBlock = exports.AddBlock = exports.DeleteCategory = exports.ConfigureCategory = exports.CreateCategory = exports.DeletePage = exports.ConfigurePage = exports.CreatePage = exports.RefreshSourceIndex = exports.GetPageStructure = exports.GetCatalog = exports.ListCategories = exports.ListPages = void 0;
3
+ exports.RemoveBlock = exports.MoveBlock = exports.ConfigureBlock = exports.AddBlock = exports.DeleteCategory = exports.ConfigureCategory = exports.CreateCategory = exports.DeletePage = exports.ConfigurePage = exports.CreatePage = exports.RefreshSourceIndex = exports.GetPageStructure = exports.ListDataSources = exports.GetCatalog = exports.ListCategories = exports.ListPages = void 0;
4
4
  const interface_core_1 = require("@antelopejs/interface-core");
5
5
  exports.ListPages = (0, interface_core_1.InterfaceFunction)();
6
6
  exports.ListCategories = (0, interface_core_1.InterfaceFunction)();
7
7
  exports.GetCatalog = (0, interface_core_1.InterfaceFunction)();
8
+ exports.ListDataSources = (0, interface_core_1.InterfaceFunction)();
8
9
  exports.GetPageStructure = (0, interface_core_1.InterfaceFunction)();
9
10
  exports.RefreshSourceIndex = (0, interface_core_1.InterfaceFunction)();
10
11
  exports.CreatePage = (0, interface_core_1.InterfaceFunction)();
@@ -1 +1 @@
1
- {"version":3,"file":"operations.js","sourceRoot":"","sources":["../src/operations.ts"],"names":[],"mappings":";;;AAAA,+DAA+D;AA4FlD,QAAA,SAAS,GAAG,IAAA,kCAAiB,GAAgC,CAAC;AAC9D,QAAA,cAAc,GACzB,IAAA,kCAAiB,GAAoC,CAAC;AAC3C,QAAA,UAAU,GAAG,IAAA,kCAAiB,GAA+B,CAAC;AAC9D,QAAA,gBAAgB,GAC3B,IAAA,kCAAiB,GAAsD,CAAC;AAM7D,QAAA,kBAAkB,GAC7B,IAAA,kCAAiB,GAA2C,CAAC;AAElD,QAAA,UAAU,GACrB,IAAA,kCAAiB,GAId,CAAC;AAOO,QAAA,aAAa,GACxB,IAAA,kCAAiB,GAMd,CAAC;AACO,QAAA,UAAU,GACrB,IAAA,kCAAiB,GAA4D,CAAC;AAEnE,QAAA,cAAc,GACzB,IAAA,kCAAiB,GAEd,CAAC;AACO,QAAA,iBAAiB,GAC5B,IAAA,kCAAiB,GAKd,CAAC;AACO,QAAA,cAAc,GACzB,IAAA,kCAAiB,GAA2C,CAAC;AAElD,QAAA,QAAQ,GACnB,IAAA,kCAAiB,GAKd,CAAC;AACO,QAAA,cAAc,GACzB,IAAA,kCAAiB,GAMd,CAAC;AACO,QAAA,SAAS,GACpB,IAAA,kCAAiB,GAEd,CAAC;AACO,QAAA,WAAW,GACtB,IAAA,kCAAiB,GAEd,CAAC"}
1
+ {"version":3,"file":"operations.js","sourceRoot":"","sources":["../src/operations.ts"],"names":[],"mappings":";;;AAAA,+DAA+D;AA4FlD,QAAA,SAAS,GAAG,IAAA,kCAAiB,GAAgC,CAAC;AAC9D,QAAA,cAAc,GACzB,IAAA,kCAAiB,GAAoC,CAAC;AAC3C,QAAA,UAAU,GAAG,IAAA,kCAAiB,GAA+B,CAAC;AAS9D,QAAA,eAAe,GAC1B,IAAA,kCAAiB,GAEd,CAAC;AACO,QAAA,gBAAgB,GAC3B,IAAA,kCAAiB,GAAsD,CAAC;AAM7D,QAAA,kBAAkB,GAC7B,IAAA,kCAAiB,GAA2C,CAAC;AAElD,QAAA,UAAU,GACrB,IAAA,kCAAiB,GAId,CAAC;AAOO,QAAA,aAAa,GACxB,IAAA,kCAAiB,GAMd,CAAC;AACO,QAAA,UAAU,GACrB,IAAA,kCAAiB,GAA4D,CAAC;AAEnE,QAAA,cAAc,GACzB,IAAA,kCAAiB,GAEd,CAAC;AACO,QAAA,iBAAiB,GAC5B,IAAA,kCAAiB,GAKd,CAAC;AACO,QAAA,cAAc,GACzB,IAAA,kCAAiB,GAA2C,CAAC;AAElD,QAAA,QAAQ,GACnB,IAAA,kCAAiB,GAKd,CAAC;AACO,QAAA,cAAc,GACzB,IAAA,kCAAiB,GAMd,CAAC;AACO,QAAA,SAAS,GACpB,IAAA,kCAAiB,GAEd,CAAC;AACO,QAAA,WAAW,GACtB,IAAA,kCAAiB,GAEd,CAAC"}
package/dist/queries.d.ts CHANGED
@@ -2,7 +2,8 @@ import type { ConfigSchema } from "./catalog";
2
2
  import type { ResourceRef } from "./resources";
3
3
  import type { MutationOpts, OpResult } from "./results";
4
4
  export type QueryRef = string;
5
- export type QueryOutputKind = "scalar";
5
+ export type QueryOutputKind = "scalar" | "series";
6
+ export type QueryResponseShape = "series" | "card" | "value" | "items";
6
7
  export type FilterOp = "eq" | "ne" | "gt" | "ge" | "lt" | "le";
7
8
  export interface QueryParamBinding {
8
9
  $param: {
@@ -24,15 +25,29 @@ export interface QueryTemplateDescriptor {
24
25
  output: QueryOutputKind;
25
26
  params: ConfigSchema;
26
27
  }
28
+ export interface QueryPoint {
29
+ x: number | string;
30
+ y: number;
31
+ }
32
+ export type QueryResponseBody = Record<string, unknown>;
33
+ export interface QueryPreview {
34
+ output: QueryOutputKind;
35
+ response?: QueryResponseShape;
36
+ body: QueryResponseBody;
37
+ truncated: boolean;
38
+ }
27
39
  export interface AddQueryInput {
28
40
  name: string;
29
41
  resource: ResourceRef;
30
42
  template: string;
31
43
  params?: Record<string, QueryParamValue>;
44
+ response?: QueryResponseShape;
45
+ compare?: boolean;
32
46
  endpoint?: string;
33
47
  }
34
48
  export interface QueryStructure {
35
49
  name: string;
50
+ response?: QueryResponseShape;
36
51
  endpoint: string;
37
52
  resource?: ResourceRef;
38
53
  modelMethod?: string;
@@ -43,6 +58,7 @@ export interface QueryStructure {
43
58
  name: string;
44
59
  in: "query" | "param" | "header";
45
60
  }[];
61
+ compare?: boolean;
46
62
  opaque?: boolean;
47
63
  opaqueReason?: "unparseable_route" | "unparseable_chain";
48
64
  }
@@ -1 +1 @@
1
- {"version":3,"file":"queries.js","sourceRoot":"","sources":["../src/queries.ts"],"names":[],"mappings":";;;AAAA,+DAA+D;AAgIlD,QAAA,kBAAkB,GAC7B,IAAA,kCAAiB,GAEd,CAAC;AAEO,QAAA,QAAQ,GACnB,IAAA,kCAAiB,GAMd,CAAC;AAEO,QAAA,cAAc,GACzB,IAAA,kCAAiB,GAMd,CAAC;AAEO,QAAA,WAAW,GACtB,IAAA,kCAAiB,GAEd,CAAC"}
1
+ {"version":3,"file":"queries.js","sourceRoot":"","sources":["../src/queries.ts"],"names":[],"mappings":";;;AAAA,+DAA+D;AAyMlD,QAAA,kBAAkB,GAC7B,IAAA,kCAAiB,GAEd,CAAC;AAEO,QAAA,QAAQ,GACnB,IAAA,kCAAiB,GAMd,CAAC;AAEO,QAAA,cAAc,GACzB,IAAA,kCAAiB,GAMd,CAAC;AAEO,QAAA,WAAW,GACtB,IAAA,kCAAiB,GAEd,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"resources.js","sourceRoot":"","sources":["../src/resources.ts"],"names":[],"mappings":";;;AAAA,+DAA+D;AA4MlD,QAAA,cAAc,GACzB,IAAA,kCAAiB,GAId,CAAC;AAcO,QAAA,iBAAiB,GAC5B,IAAA,kCAAiB,GAMd,CAAC;AAEO,QAAA,cAAc,GACzB,IAAA,kCAAiB,GAEd,CAAC;AACO,QAAA,QAAQ,GACnB,IAAA,kCAAiB,GAMd,CAAC;AACO,QAAA,cAAc,GACzB,IAAA,kCAAiB,GAMd,CAAC;AACO,QAAA,WAAW,GACtB,IAAA,kCAAiB,GAEd,CAAC;AACO,QAAA,aAAa,GACxB,IAAA,kCAAiB,GAAoC,CAAC;AAC3C,QAAA,oBAAoB,GAC/B,IAAA,kCAAiB,GAEd,CAAC"}
1
+ {"version":3,"file":"resources.js","sourceRoot":"","sources":["../src/resources.ts"],"names":[],"mappings":";;;AAAA,+DAA+D;AAgNlD,QAAA,cAAc,GACzB,IAAA,kCAAiB,GAId,CAAC;AAcO,QAAA,iBAAiB,GAC5B,IAAA,kCAAiB,GAMd,CAAC;AAOO,QAAA,cAAc,GACzB,IAAA,kCAAiB,GAEd,CAAC;AACO,QAAA,QAAQ,GACnB,IAAA,kCAAiB,GAMd,CAAC;AACO,QAAA,cAAc,GACzB,IAAA,kCAAiB,GAMd,CAAC;AACO,QAAA,WAAW,GACtB,IAAA,kCAAiB,GAEd,CAAC;AACO,QAAA,aAAa,GACxB,IAAA,kCAAiB,GAAoC,CAAC;AAC3C,QAAA,oBAAoB,GAC/B,IAAA,kCAAiB,GAEd,CAAC"}
package/dist/results.d.ts CHANGED
@@ -14,7 +14,7 @@ export interface TypecheckError {
14
14
  message: string;
15
15
  }
16
16
  export interface OpWarning {
17
- code: "index_clamped" | "datatype_fallback" | "data_not_dropped" | "route_changed";
17
+ code: "query_opaque" | "query_kept" | "index_clamped" | "datatype_fallback" | "data_not_dropped" | "route_changed";
18
18
  message: string;
19
19
  }
20
20
  export type DuplicateScope = "page" | "siblings" | "category" | "resource" | "query";
@@ -48,6 +48,9 @@ export interface PageSummary {
48
48
  category: CategoryRef;
49
49
  moduleId?: string;
50
50
  filepath: string;
51
+ icon?: string;
52
+ order?: number;
53
+ hidden?: boolean;
51
54
  }
52
55
  export interface CategorySummary {
53
56
  ref: CategoryRef;
@@ -81,6 +81,8 @@ for (const change of block.changes) {
81
81
  - [Catalog](./3.catalog.md) - The block types, DataTypes and reserved names the builder can report
82
82
  - [Page Operations](./4.page-operations.md) - Create pages and categories, and edit a page's block tree
83
83
  - [Resources](./5.resources.md) - Generate tables, DataAPI controllers and their fields
84
- - [Queries](./6.queries.md) - Compile counts and aggregates into a model method and a page route
84
+ - [Queries](./6.queries.md) - Compile a measure, grouped or not, into a model method and a page route
85
85
  - [Value Encodings](./7.values.md) - Write configuration values that are not plain literals
86
86
  - [Results and Errors](./8.results-and-errors.md) - Branch on `OpResult`, read diagnostics, and make a write conditional
87
+ - [Drafts and Preview](./9.drafts-and-preview.md) - Write a page's blocks and the data they read in one transaction, and preview either
88
+ - [A Chart Without Code](./10.a-chart-without-code.md) - The whole journey, from picking a measure to reopening the page
@@ -0,0 +1,195 @@
1
+ # A Chart Without Code
2
+
3
+ The journey the no-code data sources were built for, end to end: someone picks a
4
+ resource, a measure and a grouping, sees their own data, saves, and reopens the
5
+ page to find it as they left it. No route written, no endpoint typed.
6
+
7
+ This page follows what the engine does underneath, so a consumer building that
8
+ experience knows which call belongs where.
9
+
10
+ ## What the block asks for
11
+
12
+ A block declares the shape of answer it can read, next to its factory:
13
+
14
+ ```typescript
15
+ fetchUrl: ui(z.string().optional(), {
16
+ label: "Data source",
17
+ group: "data",
18
+ widget: "dataSource",
19
+ responseShape: "card",
20
+ periodOption: "periodScope",
21
+ });
22
+ ```
23
+
24
+ An editor reads that from the catalog and offers only calculations whose answer
25
+ the block can display — a series cannot be wired into a slot that renders one
26
+ number — and knows which of the block's own options carries the period, so
27
+ binding a source writes both in one patch.
28
+
29
+ ## Show the data before writing anything
30
+
31
+ While the person is still choosing, `PreviewQuery` runs the calculation against
32
+ the real table and answers what the route would:
33
+
34
+ ```typescript
35
+ import { PreviewQuery } from "@antelopejs/interface-dms-builder";
36
+
37
+ const preview = await PreviewQuery({
38
+ query: {
39
+ name: "revenueChart",
40
+ resource: "order",
41
+ template: "series",
42
+ params: {
43
+ op: "sum",
44
+ field: "amount",
45
+ groupBy: "createdAt",
46
+ bucket: "month",
47
+ timezone: "Europe/Brussels",
48
+ where: [{ field: "status", op: "eq", value: "paid" }],
49
+ },
50
+ response: "card",
51
+ },
52
+ });
53
+ ```
54
+
55
+ Nothing has been written at this point: no route, no model method, no file. The
56
+ numbers on screen are the ones the saved page will show, because the calculation
57
+ previewed and the calculation written are the same plan rendered two ways — and
58
+ `preview.data.body` is the envelope the route will serve, arranged by the same
59
+ helper, so the card's headline figure is previewed rather than guessed at.
60
+
61
+ ## Save the block and its data together
62
+
63
+ The query travels in the page draft, so one save writes the block, the model
64
+ method and the route in a single transaction:
65
+
66
+ ```typescript
67
+ import { SetPageBlocks } from "@antelopejs/interface-dms-builder";
68
+
69
+ await SetPageBlocks("/shop/board", {
70
+ blocks: [
71
+ {
72
+ name: "revenueCard",
73
+ type: "ChartCard",
74
+ config: {
75
+ title: "Revenue",
76
+ fetchUrl: "/shop/board/stats/revenue-chart",
77
+ periodScope: "page",
78
+ chart: {
79
+ $block: { type: "ChartArea", config: { xaxisType: "category" } },
80
+ },
81
+ },
82
+ },
83
+ ],
84
+ queries: [
85
+ {
86
+ name: "revenueChart",
87
+ resource: "order",
88
+ template: "series",
89
+ params: {
90
+ op: "sum",
91
+ field: "amount",
92
+ groupBy: "createdAt",
93
+ bucket: "month",
94
+ timezone: "Europe/Brussels",
95
+ where: [
96
+ { field: "status", op: "eq", value: "paid" },
97
+ { field: "createdAt", op: "ge", value: { $param: { name: "from" } } },
98
+ { field: "createdAt", op: "le", value: { $param: { name: "to" } } },
99
+ ],
100
+ },
101
+ response: "card",
102
+ compare: true,
103
+ },
104
+ ],
105
+ });
106
+ ```
107
+
108
+ Either all of it lands or none of it does. A draft that fails on its query leaves
109
+ the blocks as they were.
110
+
111
+ Two details the engine will hold you to. The block and the query cannot share a
112
+ name: both become members of the page class, so a query named after the block it
113
+ feeds comes back as `duplicate_name` in the `query` scope — name the block for
114
+ what it is and the query for what it answers. And a `ChartCard` requires the
115
+ chart it wraps, so a config without `chart` fails the save on `typecheck_failed`
116
+ rather than on a validation message.
117
+
118
+ ## What ends up in the project
119
+
120
+ A method on the resource's model:
121
+
122
+ ```typescript
123
+ revenueChart(minCreatedAt: Date, maxCreatedAt: Date) {
124
+ return this.table
125
+ .filter((row) => row.key("status").eq("paid"))
126
+ .filter((row) => row.key("createdAt").ge(minCreatedAt))
127
+ .filter((row) => row.key("createdAt").le(maxCreatedAt))
128
+ .map((row) => ({
129
+ bucket: row.key("createdAt").year("Europe/Brussels").mul(100).add(row.key("createdAt").month("Europe/Brussels")),
130
+ value: row.key("amount"),
131
+ }))
132
+ .group("bucket", (rows, group) => ({ x: group, y: rows.sum("value") }))
133
+ .orderBy("x", "asc");
134
+ }
135
+ ```
136
+
137
+ And a route on the page, guarded with the page's own permission:
138
+
139
+ ```typescript
140
+ @Get("/stats/revenue-chart")
141
+ async revenueChart(
142
+ @AuthUserWithPermission(BoardPage) _user: User,
143
+ @Model(OrderModel) model: OrderModel,
144
+ @Parameter("from", "query") from: string,
145
+ @Parameter("to", "query") to: string,
146
+ @Parameter("compareFrom", "query") compareFrom: string,
147
+ @Parameter("compareTo", "query") compareTo: string,
148
+ ): Promise<ChartCardData> {
149
+ return chartCardData(
150
+ await model.revenueChart(new Date(from), new Date(to)),
151
+ {
152
+ measure: "sum",
153
+ label: "Amount",
154
+ previous: await model.revenueChart(new Date(compareFrom), new Date(compareTo)),
155
+ },
156
+ );
157
+ }
158
+ ```
159
+
160
+ Ordinary code, in the application's own style, that a developer can read, edit
161
+ and own.
162
+
163
+ `label` is what a legend and a tooltip call the series. The builder takes it from
164
+ the field being measured — the label the resource gives it — and from the resource
165
+ itself for a count, which has no field to name: left out, the helper falls back to
166
+ the measure's own word, and a reader who never asked for one would be shown "sum".
167
+
168
+ ## Reopen it
169
+
170
+ `GetPageStructure` reads the route and its chain back into the parameters that
171
+ produced them, so the editor reopens filled in rather than empty:
172
+
173
+ ```typescript
174
+ const structure = await GetPageStructure("/shop/board");
175
+ const source = structure.data.queries.find((q) => q.name === "revenueChart");
176
+ // source.template === "series"
177
+ // source.response === "card"
178
+ // source.params === the parameters above, with the ordering made explicit
179
+ ```
180
+
181
+ That read-back is what makes the journey repeatable rather than one-way. It is
182
+ also what decides ownership: a chain edited past what the grammar accepts reads as
183
+ `opaque`, and from then on the builder will list it and remove it but never
184
+ rewrite it.
185
+
186
+ ## Where each guarantee lives
187
+
188
+ | Guarantee | Where it comes from |
189
+ | --- | --- |
190
+ | The preview matches the saved page | One `QueryPlan`, rendered by the emitter and run by the executor |
191
+ | The block and its data save together | `PageDraft.queries`, written in the page's transaction |
192
+ | Reopening shows what was configured | The chain reads back into its parameters |
193
+ | A developer's edit survives | An unreadable chain is opaque, and opaque is never rewritten |
194
+ | The data is not public | The route carries the page's permission |
195
+ | A tenant reads its own rows | The model provider is chosen from the resource's schema |
@@ -170,6 +170,9 @@ if (result.ok) {
170
170
  | `category` | `CategoryRef` | The category the page belongs to. |
171
171
  | `moduleId` | `string` | The module the page comes from, for pages declared inside a module. |
172
172
  | `filepath` | `string` | The file the page is declared in. |
173
+ | `icon` | `string` | The icon the menu shows beside the page, when it declares one. |
174
+ | `order` | `number` | Sorts the page among the others of its category, when it declares one. |
175
+ | `hidden` | `boolean` | Whether the menu leaves the page out; its route still answers. |
173
176
 
174
177
  `CategorySummary`:
175
178
 
package/docs/6.queries.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- A query is derived data: a count, a sum, an average taken over a resource's rows and served from a page. `AddQuery` compiles a query template into a method on the resource's model and a route on the page's controller, so a block on that page can fetch a single number without you writing either half.
5
+ A query is derived data: a count, a sum, an average taken over a resource's rows and served from a page. `AddQuery` compiles a query template into a method on the resource's model and a route on the page's controller, so a block on that page can fetch its data without you writing either half.
6
+
7
+ The route is guarded with the page's own permission, as a parameter decorator on that route alone: whoever may open the page may read what it derives, and no other route on the controller is affected.
6
8
 
7
9
  A query is addressed by a `QueryRef`, lives on a page, and reads from a resource. Templates decide what a query can compute; you pick one by id, fill in its parameters, and the builder emits the chain.
8
10
 
@@ -42,7 +44,17 @@ for (const template of templates) {
42
44
  | `output` | `QueryOutputKind` | The shape the query's route responds with. |
43
45
  | `params` | `ConfigSchema` | The template's parameter schema, in the same vocabulary as the block catalog. |
44
46
 
45
- `QueryOutputKind` is `"scalar"`: the route responds with a `{ value }` object.
47
+ `QueryOutputKind` says what the route responds with:
48
+
49
+ | Kind | Response | Templates |
50
+ | --- | --- | --- |
51
+ | `"scalar"` | `{ value }` — one number | `count`, `aggregate` |
52
+ | `"series"` | `{ series }` — one point per group, each `{ x, y }` | `series` |
53
+
54
+ A series point carries its group as `x` and what was measured as `y`. A bucketed
55
+ group is a number that sorts — `2026`, `202601`, `20260114` — rather than a label:
56
+ the group is also what the points are ordered by, and a label belongs to a
57
+ response that knows the reader's locale.
46
58
 
47
59
  Read `params` the way you read a block's `config` schema, described in [Catalog](./3.catalog.md) — it tells you which keys `AddQueryInput.params` accepts, and a key the template does not declare fails with `invalid_config`.
48
60
 
@@ -74,6 +86,8 @@ if (result.ok) {
74
86
  | `template` | `string` | A `QueryTemplateDescriptor.id`. Required. |
75
87
  | `params` | `Record<string, QueryParamValue>` | The template's parameters. |
76
88
  | `endpoint` | `string` | The route path relative to the page's slug. Defaults to `/stats/<kebab-name>`. |
89
+ | `response` | `QueryResponseShape` | How the answer is arranged for the block reading it. Defaults to what the calculation computes. |
90
+ | `compare` | `boolean` | Also answer the preceding period, so a card can show a variation. |
77
91
 
78
92
  The result carries both addresses: `query` is the `QueryRef` to configure or remove it by, `route` is the page slug joined with the endpoint — the URL a block fetches from.
79
93
 
@@ -164,6 +178,96 @@ await AddQuery("/books", {
164
178
  });
165
179
  ```
166
180
 
181
+ ## Measure per Group
182
+
183
+ The `series` template answers one point per group rather than one number, which is what a chart, a ranking or any other multi-point block reads. Group by a field's own value, or by a period of a date field.
184
+
185
+ ```typescript
186
+ import { AddQuery } from "@antelopejs/interface-dms-builder";
187
+
188
+ await AddQuery("/dashboard", {
189
+ name: "monthlyRevenue",
190
+ resource: "order",
191
+ template: "series",
192
+ params: {
193
+ op: "sum",
194
+ field: "amount",
195
+ groupBy: "createdAt",
196
+ bucket: "month",
197
+ timezone: "Europe/Brussels",
198
+ where: [{ field: "status", op: "eq", value: "paid" }],
199
+ },
200
+ });
201
+ // GET /dashboard/stats/monthly-revenue
202
+ // { series: [{ x: 202601, y: 1250 }, { x: 202602, y: 980 }] }
203
+ ```
204
+
205
+ | Option | Type | Description |
206
+ | --- | --- | --- |
207
+ | `op` | `"count" \| "sum" \| "avg" \| "min" \| "max"` | What to measure in each group. Defaults to `count`. |
208
+ | `field` | `string` | The numeric field the measure reads. Required for every op but `count`. |
209
+ | `groupBy` | `string` | The field the rows are grouped by. Required. |
210
+ | `bucket` | `"day" \| "month" \| "quarter" \| "year"` | Groups a date field by period instead of by exact value. Only for a date field. |
211
+ | `timezone` | `string` | IANA zone the period is computed in. |
212
+ | `orderBy` | `"group" \| "measure"` | Order the points by their group (a timeline) or by what was measured (a ranking). Defaults to `group`. |
213
+ | `direction` | `"asc" \| "desc"` | Defaults to `asc`. |
214
+ | `limit` | `number` | Keep only the first N points after ordering — a top N. At most 1000. |
215
+ | `where` | `QueryFilter[]` | As for every other template. |
216
+
217
+ Three rules worth planning for:
218
+
219
+ - **A series is always ordered.** Databases return groups in no particular order — MongoDB scrambles them — so an ordering is emitted whether or not you ask for one. Omitting `orderBy` gives a timeline: the group, ascending.
220
+ - **Omitting `timezone` buckets in UTC**, which moves every row near a period boundary into its neighbour. Pass the zone the reader thinks in.
221
+ - **A top N is an ordering plus a limit**, not a separate template: `orderBy: "measure"`, `direction: "desc"`, `limit: 5`.
222
+
223
+ There is no week bucket. Neither database adapter exposes an ISO week, and deriving one from the day of the year puts the first days of January in the previous year's week — a wrong week being worse than a missing one.
224
+
225
+ A series reads back like any other query: `GetPageStructure` reports its `template` and the parameters that produced it, including the ordering the engine applies when you omit one. Editing and removing it work as they do for a scalar query — the model method goes with the route.
226
+
227
+ A chain edited past what the grammar accepts is the exception, and the rule is the same as everywhere else: it reads as `opaque`, keeps its `endpoint`, `resource`, `modelMethod` and `output`, and the builder will list and remove it but never rewrite it.
228
+
229
+ ## Arrange the Answer
230
+
231
+ One calculation feeds several kinds of block, so `response` says how its answer is dressed rather than making you describe it more than once.
232
+
233
+ | Shape | Answer | Read by |
234
+ | --- | --- | --- |
235
+ | `series` | `{ series }` — the points | A bare chart |
236
+ | `card` | `{ value, series, previousValue?, delta?, comparisonSeries? }` | A chart card |
237
+ | `value` | `{ value, previousValue?, delta?, sparkline? }` | A KPI card |
238
+ | `items` | `{ items }` — the points as ranked entries | A ranked list |
239
+
240
+ The arrangements that need more than a property name are built by helpers `@antelopejs/interface-dms/base` publishes, which the generated route imports. That keeps the route to one expression, and is why a query whose answer is arranged still reads back as the parameters that produced it.
241
+
242
+ Two rules worth planning for:
243
+
244
+ - **The headline figure is taken from the points.** Counts and sums add up, a min or max is the min or max of the group figures — but an average is the average of the group averages, which is not the average over all the rows whenever the groups are uneven.
245
+ - **`compare` needs a period the route supplies.** It re-asks the same model method with the `compareFrom` / `compareTo` bounds the request carried. A calculation whose dates are baked in has nothing to shift, so it is not compared and no second call is emitted.
246
+
247
+ ```typescript
248
+ import { AddQuery } from "@antelopejs/interface-dms-builder";
249
+
250
+ await AddQuery("/dashboard", {
251
+ name: "revenue",
252
+ resource: "order",
253
+ template: "series",
254
+ params: {
255
+ op: "sum",
256
+ field: "amount",
257
+ groupBy: "createdAt",
258
+ bucket: "month",
259
+ where: [
260
+ { field: "createdAt", op: "ge", value: { $param: { name: "from" } } },
261
+ { field: "createdAt", op: "le", value: { $param: { name: "to" } } },
262
+ ],
263
+ },
264
+ response: "card",
265
+ compare: true,
266
+ });
267
+ // GET /dashboard/stats/revenue?from=…&to=…&compareFrom=…&compareTo=…
268
+ // { value, series, previousValue, delta, comparisonSeries }
269
+ ```
270
+
167
271
  ## Configure a Query
168
272
 
169
273
  `ConfigureQuery(query, patch, opts?)` recompiles the query from the merged patch and replaces both emitted members. Fields you leave out keep their current value, read back off disk.
@@ -199,8 +303,8 @@ await RemoveQuery("/books@cheaperThan");
199
303
  | `name` | `string` | The query name. Always present. |
200
304
  | `endpoint` | `string` | The route path relative to the page's slug, as `AddQueryInput.endpoint` takes it. Always present. |
201
305
  | `resource` | `ResourceRef` | The resource the route's model parameter points at. |
202
- | `modelMethod` | `string` | The model method the route calls. Several queries may share one method, but only while they want the exact same chain. |
203
- | `output` | `QueryOutputKind` | The output shape, from the template descriptor. |
306
+ | `modelMethod` | `string` | The model method the route calls. Sharing is driven by the query's *name*: a query looks for its own name and numbered forks of it, so two differently-named queries never share a method however identical their chains. |
307
+ | `output` | `QueryOutputKind` | The output shape, read from the property the route's response carries — so a query whose chain no longer parses still reports what it answers. |
204
308
  | `template` | `string` | The template the chain was compiled from. |
205
309
  | `params` | `Record<string, unknown>` | The parameters that produced the chain. |
206
310
  | `routeParams` | `{ name: string; in: "query" \| "param" \| "header" }[]` | The route's declared parameters — the source of truth for a bound value's `name` and `in`. `header` appears only on hand-written routes. |
@@ -21,6 +21,39 @@ import {
21
21
  | --- | --- | --- |
22
22
  | `page` | `Partial<EditablePageMeta>` | Metadata to patch alongside the blocks. Omit to leave it alone. |
23
23
  | `blocks` | `BlockDraft[]` | The top-level blocks, in the order they should appear. |
24
+ | `queries` | `AddQueryInput[]` | The queries the page should serve, written with the blocks. Omit to leave the page's queries alone. |
25
+
26
+ A block and the data it reads are one edit, so they are one write: the blocks, the model methods and the routes land in a single transaction with a single typecheck, or none of them land.
27
+
28
+ Omitting `queries` is not the same as passing an empty array. Omitted leaves whatever the page already serves untouched — which is what a caller written before queries joined the draft keeps doing. An empty array says the page should serve none, and removes the generated ones.
29
+
30
+ Two things a draft never destroys silently:
31
+
32
+ - **A query edited past the grammar** is left exactly as it is, whether the draft still lists it or not. The engine reports `query_opaque` and moves on, because a chain it cannot read is one a human owns.
33
+ - **A query the draft forgot** is kept when a block on the page still reads its URL, reported as `query_kept`. A draft that dropped a source a block depends on is a draft that would break that block, and keeping it is the recoverable side of the mistake.
34
+
35
+ ```typescript
36
+ import { SetPageBlocks } from "@antelopejs/interface-dms-builder";
37
+
38
+ await SetPageBlocks("/shop/board", {
39
+ blocks: [
40
+ {
41
+ name: "revenueChart",
42
+ type: "ChartCard",
43
+ config: { title: "Revenue", fetchUrl: "/shop/board/stats/revenue" },
44
+ },
45
+ ],
46
+ queries: [
47
+ {
48
+ name: "revenue",
49
+ resource: "order",
50
+ template: "series",
51
+ params: { op: "sum", field: "amount", groupBy: "createdAt", bucket: "month" },
52
+ response: "card",
53
+ },
54
+ ],
55
+ });
56
+ ```
24
57
 
25
58
  `BlockDraft` is one block:
26
59
 
@@ -134,7 +167,41 @@ if (preview.ok) {
134
167
 
135
168
  A block lands in `degraded` when the preview cannot reach what the running page holds: a controller-leading block such as `TableView` (the DataAPI class lives in the app, not in the builder), a `$ref`, an `$expr`, a `preserve` block (which the draft does not describe), or a factory that threw. Render the last known-good layout for those rather than trusting the preview.
136
169
 
170
+ ## Preview a Query
171
+
172
+ `PreviewLayout` says what the page will look like; `PreviewQuery` says what its blocks will be showing. It runs a query the caller has not saved and answers what its route would, so a source can be configured against real data before anything is written.
173
+
174
+ ```typescript
175
+ import { PreviewQuery } from "@antelopejs/interface-dms-builder";
176
+
177
+ const result = await PreviewQuery({
178
+ query: {
179
+ name: "revenue",
180
+ resource: "order",
181
+ template: "series",
182
+ params: { op: "sum", field: "amount", groupBy: "createdAt", bucket: "month" },
183
+ },
184
+ args: { from: "2026-01-01T00:00:00.000Z", to: "2026-09-01T00:00:00.000Z" },
185
+ });
186
+ if (result.ok) {
187
+ console.log(result.data.body); // { series: [{ x: 202601, y: 1250 }, …] }
188
+ console.log(result.data.truncated); // whether it stopped reading early
189
+ }
190
+ ```
191
+
192
+ | Field | Type | Description |
193
+ | --- | --- | --- |
194
+ | `query` | `AddQueryInput` | What to run. Validated the same way `AddQuery` validates it. |
195
+ | `args` | `Record<string, unknown>` | Values the route would receive, by the name it exposes them under. A bound value with nothing supplied is refused rather than compared against nothing. |
196
+ | `tenant` | `string` | Whose rows to read, for a resource in a per-tenant schema. |
197
+
198
+ What comes back is the route's own response body, not a rendering of it: `body` is built by the same helper the generated route would call, and `output` and `response` report it the way `GetPageStructure` reports the saved query. Ask for `response: "card"` and the preview answers a `ChartCardData` — the headline figure included — so a block can be handed it directly. `response` is set only when a helper arranged the body, which is the same condition read-back reports it under; a plain `{ value }` or `{ series }` leaves it absent. An arrangement the installed DMS does not publish is refused as `unsupported` rather than answered as bare points, because saving that query would fail its typecheck on the very same helper.
199
+
200
+ Read-only by construction: a query is filters, a grouping and a measure, and there is no way to spell a write in one. It stops at 500 points and sets `truncated`, because a chart that quietly lost its tail is worse than one that reports it. A database that refuses comes back as a failed `OpResult`, not as a rejection.
201
+
137
202
  ## Next Steps
138
203
 
204
+ - [A Chart Without Code](./10.a-chart-without-code.md) - The journey these calls add up to
205
+
139
206
  - [Results and Errors](./8.results-and-errors.md) - Branch on `OpResult` and handle `stale`
140
207
  - [Catalog](./3.catalog.md) - The block types, their schemas and their defaults
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/interface-dms-builder",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",