@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 +23 -1
- package/dist/drafts.d.ts +8 -0
- package/dist/drafts.js +2 -1
- package/dist/drafts.js.map +1 -1
- package/dist/operations.d.ts +2 -1
- package/dist/operations.js +2 -1
- package/dist/operations.js.map +1 -1
- package/dist/queries.d.ts +17 -1
- package/dist/queries.js.map +1 -1
- package/dist/resources.js.map +1 -1
- package/dist/results.d.ts +1 -1
- package/dist/structure.d.ts +3 -0
- package/docs/1.introduction.md +3 -1
- package/docs/10.a-chart-without-code.md +195 -0
- package/docs/2.addressing-and-structure.md +3 -0
- package/docs/6.queries.md +108 -4
- package/docs/9.drafts-and-preview.md +67 -0
- package/package.json +1 -1
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
|
package/dist/drafts.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"drafts.js","sourceRoot":"","sources":["../src/drafts.ts"],"names":[],"mappings":";;;AAAA,+DAA+D;
|
|
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"}
|
package/dist/operations.d.ts
CHANGED
|
@@ -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<{
|
package/dist/operations.js
CHANGED
|
@@ -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)();
|
package/dist/operations.js.map
CHANGED
|
@@ -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;
|
|
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
|
}
|
package/dist/queries.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"queries.js","sourceRoot":"","sources":["../src/queries.ts"],"names":[],"mappings":";;;AAAA,+DAA+D;
|
|
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"}
|
package/dist/resources.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resources.js","sourceRoot":"","sources":["../src/resources.ts"],"names":[],"mappings":";;;AAAA,+DAA+D;
|
|
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";
|
package/dist/structure.d.ts
CHANGED
package/docs/1.introduction.md
CHANGED
|
@@ -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
|
|
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
|
|
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`
|
|
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.
|
|
203
|
-
| `output` | `QueryOutputKind` | The output shape, from the
|
|
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
|