@softr/sdk 0.0.25 → 0.0.26
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/action.d.ts +195 -0
- package/dist/action.js +21 -0
- package/dist/auth/isomorphic.d.ts +1 -2
- package/dist/auth/permissions.d.ts +7 -2
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +639 -0
- package/dist/codegen/datasource-types.js +471 -0
- package/dist/codegen/softr-env.js +103 -0
- package/dist/codegen/user-groups.js +11 -0
- package/dist/core/integration.js +1 -1
- package/dist/datasource/chart.d.ts +196 -0
- package/dist/datasource/chart.js +289 -0
- package/dist/datasource/datasource.d.ts +16 -0
- package/dist/datasource/datasource.js +17 -0
- package/dist/datasource/fields.d.ts +122 -0
- package/dist/datasource/fields.js +82 -0
- package/dist/datasource/filter.d.ts +30 -0
- package/dist/datasource/filter.js +146 -0
- package/dist/datasource/record.d.ts +138 -0
- package/dist/datasource/record.js +258 -0
- package/dist/index.d.ts +3 -5
- package/dist/index.js +2 -2
- package/dist/package.js +5 -0
- package/dist/routes/visibility.d.ts +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/package.json +2 -1
- package/skills/softr-sdk/SKILL.md +6 -2
- package/skills/softr-sdk/auth/SKILL.md +1 -1
- package/skills/softr-sdk/current-user/SKILL.md +1 -1
- package/skills/softr-sdk/data-mutations/SKILL.md +1 -1
- package/skills/softr-sdk/integrations/SKILL.md +140 -0
- package/skills/softr-sdk/reading-records/SKILL.md +1 -1
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +1 -1
- package/dist/core/datasource.d.ts +0 -31
- package/dist/core/datasource.js +0 -226
- package/dist/core/query.d.ts +0 -354
- package/dist/core/query.js +0 -528
- package/dist/core/utils.js +0 -14
- package/dist/types.d.ts +0 -128
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { Action, FieldTypeOf, ReadShape, SoftrDatasources } from "../action.js";
|
|
2
|
+
import { ConstructableOrderBy } from "./filter.js";
|
|
3
|
+
import { DateFieldType, NumberFieldType, SortSet } from "./fields.js";
|
|
4
|
+
import { WhereCallback } from "./record.js";
|
|
5
|
+
|
|
6
|
+
//#region src/datasource/chart.d.ts
|
|
7
|
+
declare const dimensionValue: unique symbol;
|
|
8
|
+
declare const measureValue: unique symbol;
|
|
9
|
+
/** A chart column that groups rows. The brand carries the value type the column reads back as. */
|
|
10
|
+
type Dimension<TValue$1> = {
|
|
11
|
+
readonly [dimensionValue]: TValue$1;
|
|
12
|
+
};
|
|
13
|
+
/** A chart column that aggregates rows. The brand carries the value type the column reads back as. */
|
|
14
|
+
type Measure<TValue$1> = {
|
|
15
|
+
readonly [measureValue]: TValue$1;
|
|
16
|
+
};
|
|
17
|
+
type ColumnValue<T> = T extends Dimension<infer TValue> ? TValue : T extends Measure<infer TValue> ? TValue : never;
|
|
18
|
+
type DateBuckets = {
|
|
19
|
+
/** Groups by calendar day. The value reads as `"2025-06-14"`. */
|
|
20
|
+
readonly byDay: Dimension<string | null>;
|
|
21
|
+
/**
|
|
22
|
+
* Groups by calendar week. The value is the ISO date of the week's Monday. Monday-based,
|
|
23
|
+
* while `byDayOfWeek` counts from Sunday: the server disagrees with itself, so the two
|
|
24
|
+
* cannot be lined up by arithmetic alone.
|
|
25
|
+
*/
|
|
26
|
+
readonly byWeek: Dimension<string | null>;
|
|
27
|
+
/** Groups by calendar month. The value reads as `"2025-06"`. */
|
|
28
|
+
readonly byMonth: Dimension<string | null>;
|
|
29
|
+
/** Groups by calendar quarter. The value is a display string like `"Q2 2025"`, not a parseable date. */
|
|
30
|
+
readonly byQuarter: Dimension<string | null>;
|
|
31
|
+
/** Groups by calendar year. The value is the year as a number. */
|
|
32
|
+
readonly byYear: Dimension<number | null>;
|
|
33
|
+
/**
|
|
34
|
+
* Groups by day of week, 1 through 7 with 1 as Sunday. Sunday-based, while `byWeek` is
|
|
35
|
+
* Monday-based: the server disagrees with itself, so the two cannot be lined up by
|
|
36
|
+
* arithmetic alone.
|
|
37
|
+
*/
|
|
38
|
+
readonly byDayOfWeek: Dimension<number | null>;
|
|
39
|
+
/** Groups by month of year, 1 through 12. */
|
|
40
|
+
readonly byMonthOfYear: Dimension<number | null>;
|
|
41
|
+
/** Groups by quarter of year, 1 through 4. */
|
|
42
|
+
readonly byQuarterOfYear: Dimension<number | null>;
|
|
43
|
+
};
|
|
44
|
+
type DatetimeBuckets = DateBuckets & {
|
|
45
|
+
/** Groups by hour of day, 0 through 23. */
|
|
46
|
+
readonly byHourOfDay: Dimension<number | null>;
|
|
47
|
+
};
|
|
48
|
+
type DateDimension = Dimension<string | null> & DateBuckets;
|
|
49
|
+
type DatetimeDimension = Dimension<string | null> & DatetimeBuckets;
|
|
50
|
+
type NumericMeasures = {
|
|
51
|
+
/** Adds the group's values. `null` when the group has none. */
|
|
52
|
+
readonly sum: Measure<number | null>;
|
|
53
|
+
/** Averages the group's values, rounded to two decimals. `null` when the group has none. */
|
|
54
|
+
readonly avg: Measure<number | null>;
|
|
55
|
+
/** The group's smallest value. `null` when the group has none. */
|
|
56
|
+
readonly min: Measure<number | null>;
|
|
57
|
+
/** The group's largest value. `null` when the group has none. */
|
|
58
|
+
readonly max: Measure<number | null>;
|
|
59
|
+
/** Counts the group's distinct values. */
|
|
60
|
+
readonly distinct: Measure<number>;
|
|
61
|
+
};
|
|
62
|
+
type DateMeasures = {
|
|
63
|
+
/** The group's earliest date, as `"2025-06-14"`. `null` when the group has none. */
|
|
64
|
+
readonly min: Measure<string | null>;
|
|
65
|
+
/** The group's latest date, as `"2025-06-14"`. `null` when the group has none. */
|
|
66
|
+
readonly max: Measure<string | null>;
|
|
67
|
+
/** Counts the group's distinct values. */
|
|
68
|
+
readonly distinct: Measure<number>;
|
|
69
|
+
};
|
|
70
|
+
type AnyDimension = Dimension<unknown> & DatetimeBuckets;
|
|
71
|
+
type AnyMeasures = {
|
|
72
|
+
readonly sum: Measure<number | null>;
|
|
73
|
+
readonly avg: Measure<number | null>;
|
|
74
|
+
readonly min: Measure<unknown>;
|
|
75
|
+
readonly max: Measure<unknown>;
|
|
76
|
+
readonly distinct: Measure<number>;
|
|
77
|
+
};
|
|
78
|
+
/** A field a chart cannot use for this: the missing leaves are the compile error saying so. */
|
|
79
|
+
type Unusable = {
|
|
80
|
+
readonly [dimensionValue]?: never;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* The handles a `groupBy` callback is handed: one per alias. The bare handle groups by the
|
|
84
|
+
* field's value; a date field also carries bucket leaves. A multi-value field has no handle
|
|
85
|
+
* body, because exploding it would count each record once per value and inflate every metric.
|
|
86
|
+
*/
|
|
87
|
+
type DimensionSet<TShape extends ReadShape> = keyof SoftrDatasources extends never ? Record<string, AnyDimension> : { [Alias in keyof TShape["fields"]]: DimensionFor<FieldTypeOf<TShape["datasource"], TShape["fields"][Alias]>> };
|
|
88
|
+
type DimensionFor<TFieldType> = [TFieldType] extends [never] ? AnyDimension : TFieldType extends `${string}[]` | "ATTACHMENT" | "DATE_RANGE" ? Unusable : TFieldType extends "DATE" ? DateDimension : TFieldType extends DateFieldType ? DatetimeDimension : TFieldType extends NumberFieldType ? Dimension<number | null> : TFieldType extends "CHECKBOX" ? Dimension<boolean | null> : Dimension<string | null>;
|
|
89
|
+
/**
|
|
90
|
+
* The handles a `metrics` callback is handed: aggregation leaves per alias, gated by the
|
|
91
|
+
* field's declared type, plus `$count`. A field whose aggregate would come back as a
|
|
92
|
+
* coerced non-number has no leaves at all.
|
|
93
|
+
*/
|
|
94
|
+
type MeasureSet<TShape extends ReadShape> = (keyof SoftrDatasources extends never ? Record<string, AnyMeasures> : { [Alias in keyof TShape["fields"]]: MeasureFor<FieldTypeOf<TShape["datasource"], TShape["fields"][Alias]>> }) & {
|
|
95
|
+
/** Counts the rows in each group. Takes no field: every row counts once. */
|
|
96
|
+
readonly $count: Measure<number>;
|
|
97
|
+
};
|
|
98
|
+
type MeasureFor<TFieldType> = [TFieldType] extends [never] ? AnyMeasures : TFieldType extends `${string}[]` ? Unusable : TFieldType extends NumberFieldType ? NumericMeasures : TFieldType extends DateFieldType ? DateMeasures : Unusable;
|
|
99
|
+
type IsUnion<T, TAll = T> = [T] extends [never] ? false : T extends unknown ? [TAll] extends [T] ? false : true : never;
|
|
100
|
+
type MoreThanTwo<TKeys, TAll = TKeys> = TKeys extends unknown ? IsUnion<Exclude<TAll, TKeys>> : never;
|
|
101
|
+
type GroupByBounds<TGroups> = [keyof TGroups] extends [never] ? {
|
|
102
|
+
__error: "a chart needs at least one column to group by";
|
|
103
|
+
} : true extends MoreThanTwo<keyof TGroups> ? {
|
|
104
|
+
__error: "a chart may group by at most two columns";
|
|
105
|
+
} : unknown;
|
|
106
|
+
type MetricBounds<TMetrics, TGroups> = [Extract<keyof TMetrics, keyof TGroups>] extends [never] ? unknown : {
|
|
107
|
+
__error: "a metric key must not collide with a group key";
|
|
108
|
+
};
|
|
109
|
+
/** One row, keyed by the caller's own `groupBy` and `metrics` keys. */
|
|
110
|
+
type ChartRow<TGroups, TMetrics> = { [K in keyof TGroups | keyof TMetrics]: ColumnValue<(TGroups & TMetrics)[K]> };
|
|
111
|
+
interface ChartResult<TRow> {
|
|
112
|
+
data: Array<TRow>;
|
|
113
|
+
executionTimestamp: Date;
|
|
114
|
+
}
|
|
115
|
+
/** A chart before `groupBy`. It cannot be awaited: an un-grouped chart is not a request. */
|
|
116
|
+
type ChartSeed<TShape extends ReadShape> = {
|
|
117
|
+
/**
|
|
118
|
+
* Names the group columns: one or two entries, each a handle from the callback, keyed by
|
|
119
|
+
* the name the row will carry. A bare handle groups by the field's value; a bucket leaf
|
|
120
|
+
* such as `q.closedAt.byMonth` groups a date field by the bucket.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ds.chart(sales).groupBy((q) => ({ month: q.closedAt.byMonth, region: q.region }))
|
|
124
|
+
*/
|
|
125
|
+
groupBy<TGroups extends Record<string, Dimension<unknown>>>(build: (q: DimensionSet<TShape>) => TGroups & GroupByBounds<TGroups>): ChartBuilder<TShape, TGroups, {}>;
|
|
126
|
+
/**
|
|
127
|
+
* Narrows which records the chart aggregates. Same filters as `ds.list`.
|
|
128
|
+
*/
|
|
129
|
+
where(build: WhereCallback<TShape>): ChartSeed<TShape>;
|
|
130
|
+
};
|
|
131
|
+
type ChartBuilder<TShape extends ReadShape, TGroups, TMetrics> = {
|
|
132
|
+
/**
|
|
133
|
+
* Names the aggregated columns, keyed by the name the row will carry. Omit the call for a
|
|
134
|
+
* distinct-values query: the rows then hold the group columns alone.
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ds.chart(sales)
|
|
138
|
+
* .groupBy((q) => ({ region: q.region }))
|
|
139
|
+
* .metrics((q) => ({ revenue: q.amount.sum, orders: q.$count }))
|
|
140
|
+
*/
|
|
141
|
+
metrics<TNext extends Record<string, Measure<unknown>>>(build: (q: MeasureSet<TShape>) => TNext & MetricBounds<TNext, TGroups>): ChartBuilder<TShape, TGroups, TNext>;
|
|
142
|
+
/**
|
|
143
|
+
* Narrows which records the chart aggregates. Same filters as `ds.list`.
|
|
144
|
+
*/
|
|
145
|
+
where(build: WhereCallback<TShape>): ChartBuilder<TShape, TGroups, TMetrics>;
|
|
146
|
+
/**
|
|
147
|
+
* Sorts the rows by the chart's own keys. Call it last: it sees the keys declared so far,
|
|
148
|
+
* so placed before `metrics` a metric key is not yet a column. Without a sort the rows come
|
|
149
|
+
* back by the first bucket ascending, else by the first metric descending.
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* ds.chart(sales)
|
|
153
|
+
* .groupBy((q) => ({ month: q.closedAt.byMonth }))
|
|
154
|
+
* .metrics((q) => ({ revenue: q.amount.sum }))
|
|
155
|
+
* .orderBy((q) => [q.month.asc, q.revenue.desc])
|
|
156
|
+
*/
|
|
157
|
+
orderBy(build: (q: SortSet<ChartRow<TGroups, TMetrics>>) => ConstructableOrderBy | Array<ConstructableOrderBy>): ChartBuilder<TShape, TGroups, TMetrics>;
|
|
158
|
+
} & Promise<ChartResult<ChartRow<TGroups, TMetrics>>>;
|
|
159
|
+
interface MetricResult<TValue$1> {
|
|
160
|
+
value: TValue$1;
|
|
161
|
+
executionTimestamp: Date;
|
|
162
|
+
}
|
|
163
|
+
type MetricBuilder<TShape extends ReadShape, TValue$1> = {
|
|
164
|
+
/**
|
|
165
|
+
* Narrows which records the metric aggregates. Same filters as `ds.list`.
|
|
166
|
+
*/
|
|
167
|
+
where(build: WhereCallback<TShape>): MetricBuilder<TShape, TValue$1>;
|
|
168
|
+
} & Promise<MetricResult<TValue$1>>;
|
|
169
|
+
/**
|
|
170
|
+
* Aggregates records into chart rows: one row per group, keyed by the caller's own names.
|
|
171
|
+
*
|
|
172
|
+
* `groupBy` is required and takes one or two columns. `metrics` is optional; without it the
|
|
173
|
+
* rows hold the distinct group values. `orderBy` comes last, over the chart's own keys.
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* const { data } = await ds
|
|
177
|
+
* .chart(sales)
|
|
178
|
+
* .groupBy((q) => ({ month: q.closedAt.byMonth }))
|
|
179
|
+
* .metrics((q) => ({ revenue: q.amount.sum, orders: q.$count }))
|
|
180
|
+
* .where((q) => q.status.is("paid"))
|
|
181
|
+
* .orderBy((q) => q.month.asc)
|
|
182
|
+
* // data: Array<{ month: string | null; revenue: number | null; orders: number }>
|
|
183
|
+
*/
|
|
184
|
+
declare function chart<TShape extends ReadShape>(query: Action<TShape>): ChartSeed<TShape>;
|
|
185
|
+
/**
|
|
186
|
+
* Aggregates records into one value, with no grouping.
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* const { value } = await ds
|
|
190
|
+
* .metric(sales, (q) => q.amount.sum)
|
|
191
|
+
* .where((q) => q.status.is("paid"))
|
|
192
|
+
* // value: number | null
|
|
193
|
+
*/
|
|
194
|
+
declare function metric<TShape extends ReadShape, TMeasure extends Measure<unknown>>(query: Action<TShape>, pick: (q: MeasureSet<TShape>) => TMeasure): MetricBuilder<TShape, ColumnValue<TMeasure>>;
|
|
195
|
+
//#endregion
|
|
196
|
+
export { chart, metric };
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { assert, panic } from "../assertions.js";
|
|
2
|
+
import { readUserTimezone } from "../cookies.js";
|
|
3
|
+
import { readSessionToken } from "../auth/session.js";
|
|
4
|
+
import { INTERNAL_KIND, buildFilterCriteria, buildOrderBy, dealiasFilterCriteria } from "./filter.js";
|
|
5
|
+
import { andAll, makeFieldSet, makeSortSet } from "./fields.js";
|
|
6
|
+
import { dsUrl, resolveTarget } from "./record.js";
|
|
7
|
+
|
|
8
|
+
//#region src/datasource/chart.ts
|
|
9
|
+
const COUNT_CNT = "COUNT_CNT";
|
|
10
|
+
function makeDimensionSet(select) {
|
|
11
|
+
const set = {};
|
|
12
|
+
for (const [alias, fieldId] of Object.entries(select)) set[alias] = Object.assign(dimension(fieldId), {
|
|
13
|
+
byDay: dimension(fieldId, {
|
|
14
|
+
timeInterval: "DAY",
|
|
15
|
+
displayFormat: "YYYY-MM-DD"
|
|
16
|
+
}),
|
|
17
|
+
byWeek: dimension(fieldId, { timeInterval: "WEEK" }),
|
|
18
|
+
byMonth: dimension(fieldId, {
|
|
19
|
+
timeInterval: "MONTH",
|
|
20
|
+
displayFormat: "YYYY-MM"
|
|
21
|
+
}),
|
|
22
|
+
byQuarter: dimension(fieldId, { timeInterval: "QUARTER" }),
|
|
23
|
+
byYear: dimension(fieldId, { timeInterval: "YEAR" }),
|
|
24
|
+
byHourOfDay: dimension(fieldId, { timeInterval: "HOUR_OF_DAY" }),
|
|
25
|
+
byDayOfWeek: dimension(fieldId, { timeInterval: "DAY_OF_WEEK" }),
|
|
26
|
+
byMonthOfYear: dimension(fieldId, { timeInterval: "MONTH_OF_YEAR" }),
|
|
27
|
+
byQuarterOfYear: dimension(fieldId, { timeInterval: "QUARTER_OF_YEAR" })
|
|
28
|
+
});
|
|
29
|
+
return set;
|
|
30
|
+
}
|
|
31
|
+
function dimension(fieldId, options) {
|
|
32
|
+
return options ? {
|
|
33
|
+
fieldId,
|
|
34
|
+
options,
|
|
35
|
+
[INTERNAL_KIND]: "DIMENSION"
|
|
36
|
+
} : {
|
|
37
|
+
fieldId,
|
|
38
|
+
[INTERNAL_KIND]: "DIMENSION"
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function makeMeasureSet(select) {
|
|
42
|
+
const set = {};
|
|
43
|
+
for (const [alias, fieldId] of Object.entries(select)) set[alias] = {
|
|
44
|
+
sum: measure("SUM", fieldId),
|
|
45
|
+
avg: measure("AVG", fieldId),
|
|
46
|
+
min: measure("MIN", fieldId),
|
|
47
|
+
max: measure("MAX", fieldId),
|
|
48
|
+
distinct: measure("DISTINCT", fieldId)
|
|
49
|
+
};
|
|
50
|
+
set["$count"] = {
|
|
51
|
+
aggregation: "COUNT",
|
|
52
|
+
fieldId: null,
|
|
53
|
+
[INTERNAL_KIND]: "MEASURE"
|
|
54
|
+
};
|
|
55
|
+
return set;
|
|
56
|
+
}
|
|
57
|
+
function measure(aggregation, fieldId) {
|
|
58
|
+
return {
|
|
59
|
+
aggregation,
|
|
60
|
+
fieldId,
|
|
61
|
+
[INTERNAL_KIND]: "MEASURE"
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function isHandle(value, kind) {
|
|
65
|
+
return typeof value === "object" && value !== null && value[INTERNAL_KIND] === kind;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Aggregates records into chart rows: one row per group, keyed by the caller's own names.
|
|
69
|
+
*
|
|
70
|
+
* `groupBy` is required and takes one or two columns. `metrics` is optional; without it the
|
|
71
|
+
* rows hold the distinct group values. `orderBy` comes last, over the chart's own keys.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* const { data } = await ds
|
|
75
|
+
* .chart(sales)
|
|
76
|
+
* .groupBy((q) => ({ month: q.closedAt.byMonth }))
|
|
77
|
+
* .metrics((q) => ({ revenue: q.amount.sum, orders: q.$count }))
|
|
78
|
+
* .where((q) => q.status.is("paid"))
|
|
79
|
+
* .orderBy((q) => q.month.asc)
|
|
80
|
+
* // data: Array<{ month: string | null; revenue: number | null; orders: number }>
|
|
81
|
+
*/
|
|
82
|
+
function chart(query) {
|
|
83
|
+
const { select } = resolveTarget(query, "read");
|
|
84
|
+
const aliases = Object.keys(select);
|
|
85
|
+
const state = {
|
|
86
|
+
filters: [],
|
|
87
|
+
orderBys: []
|
|
88
|
+
};
|
|
89
|
+
let result;
|
|
90
|
+
const run = () => result ??= executeChart(query, state);
|
|
91
|
+
const builder = {
|
|
92
|
+
[Symbol.toStringTag]: "ChartQuery",
|
|
93
|
+
groupBy(build) {
|
|
94
|
+
assert(!state.groups, "groupBy may only be called once");
|
|
95
|
+
const entries = Object.entries(build(makeDimensionSet(select)));
|
|
96
|
+
for (const [, handle] of entries) assert(isHandle(handle, "DIMENSION"), "a chart column must come from the callback a chart hands you");
|
|
97
|
+
assert(entries.length <= 2, "a chart may group by at most two columns");
|
|
98
|
+
const groups = entries;
|
|
99
|
+
assert(groups.length < 2 || groups[0][1].fieldId !== groups[1][1].fieldId, "a chart may group by a field only once");
|
|
100
|
+
state.groups = groups;
|
|
101
|
+
return builder;
|
|
102
|
+
},
|
|
103
|
+
metrics(build) {
|
|
104
|
+
assert(!state.metrics, "metrics may only be called once");
|
|
105
|
+
const entries = Object.entries(build(makeMeasureSet(select)));
|
|
106
|
+
for (const [, handle] of entries) assert(isHandle(handle, "MEASURE"), "a chart column must come from the callback a chart hands you");
|
|
107
|
+
const metrics = entries;
|
|
108
|
+
const seen = /* @__PURE__ */ new Set();
|
|
109
|
+
for (const [, handle] of metrics) {
|
|
110
|
+
const pair = `${handle.aggregation} ${handle.fieldId ?? ""}`;
|
|
111
|
+
assert(!seen.has(pair), "two metrics ask for the same value");
|
|
112
|
+
seen.add(pair);
|
|
113
|
+
}
|
|
114
|
+
state.metrics = metrics;
|
|
115
|
+
return builder;
|
|
116
|
+
},
|
|
117
|
+
where(build) {
|
|
118
|
+
state.filters.push(build(makeFieldSet(aliases)));
|
|
119
|
+
return builder;
|
|
120
|
+
},
|
|
121
|
+
orderBy(build) {
|
|
122
|
+
const sorting = build(makeSortSet([...state.groups ?? [], ...state.metrics ?? []].map(([key]) => key)));
|
|
123
|
+
state.orderBys.push(...buildOrderBy(Array.isArray(sorting) ? sorting : [sorting]));
|
|
124
|
+
return builder;
|
|
125
|
+
},
|
|
126
|
+
then(onfulfilled, onrejected) {
|
|
127
|
+
return run().then(onfulfilled, onrejected);
|
|
128
|
+
},
|
|
129
|
+
catch(onrejected) {
|
|
130
|
+
return run().catch(onrejected);
|
|
131
|
+
},
|
|
132
|
+
finally(onfinally) {
|
|
133
|
+
return run().finally(onfinally);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
return builder;
|
|
137
|
+
}
|
|
138
|
+
function buildChartRequest(state, select) {
|
|
139
|
+
const groups = state.groups;
|
|
140
|
+
assert(groups && groups.length > 0, "a chart needs at least one column to group by");
|
|
141
|
+
const metrics = state.metrics ?? [];
|
|
142
|
+
const grouped = new Set(groups.map(([, handle]) => handle.fieldId));
|
|
143
|
+
for (const [, handle] of metrics) assert(handle.fieldId === null || !grouped.has(handle.fieldId), "a field cannot be grouped and aggregated in one chart");
|
|
144
|
+
const suffixed = metrics.length > 1;
|
|
145
|
+
const columns = [...groups.map(([key, handle]) => ({
|
|
146
|
+
key,
|
|
147
|
+
responseKey: handle.fieldId
|
|
148
|
+
})), ...metrics.map(([key, handle]) => ({
|
|
149
|
+
key,
|
|
150
|
+
responseKey: handle.aggregation === "COUNT" && !handle.fieldId ? COUNT_CNT : `${handle.fieldId}${suffixed ? `_${handle.aggregation.toLowerCase()}` : ""}`
|
|
151
|
+
}))];
|
|
152
|
+
let orderBys = state.orderBys;
|
|
153
|
+
if (orderBys.length === 0) {
|
|
154
|
+
const bucketed = groups.find(([, handle]) => handle.options !== void 0);
|
|
155
|
+
const [key, sortType] = bucketed ? [bucketed[0], "ASC"] : metrics.length > 0 ? [metrics[0][0], "DESC"] : [groups[0][0], "ASC"];
|
|
156
|
+
orderBys = [{
|
|
157
|
+
sortingField: key,
|
|
158
|
+
sortType
|
|
159
|
+
}];
|
|
160
|
+
}
|
|
161
|
+
const responseKeyOf = new Map(columns.map((column) => [column.key, column.responseKey]));
|
|
162
|
+
const sortingOptions = orderBys.map((orderBy) => ({
|
|
163
|
+
sortingField: responseKeyOf.get(orderBy.sortingField) ?? panic(() => `"${orderBy.sortingField}" is not a column of this chart`),
|
|
164
|
+
sortType: orderBy.sortType
|
|
165
|
+
}));
|
|
166
|
+
return {
|
|
167
|
+
body: {
|
|
168
|
+
filterCriteria: toFilterCriteria(state.filters, select),
|
|
169
|
+
sortingOptions,
|
|
170
|
+
groupBy: groups.map(([, handle]) => handle.options ? {
|
|
171
|
+
field: handle.fieldId,
|
|
172
|
+
options: handle.options
|
|
173
|
+
} : { field: handle.fieldId }),
|
|
174
|
+
metrics: metrics.map(([, handle]) => ({
|
|
175
|
+
field: handle.fieldId ?? "",
|
|
176
|
+
aggregation: handle.aggregation
|
|
177
|
+
}))
|
|
178
|
+
},
|
|
179
|
+
columns
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function mapChartRows(columns, data) {
|
|
183
|
+
return data.map((item) => {
|
|
184
|
+
const row = {};
|
|
185
|
+
for (const { key, responseKey } of columns) {
|
|
186
|
+
assert(responseKey in item, () => `the response has no column for "${key}"`);
|
|
187
|
+
row[key] = item[responseKey];
|
|
188
|
+
}
|
|
189
|
+
return row;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
async function executeChart(query, state) {
|
|
193
|
+
const { datasourceId, select } = resolveTarget(query, "read");
|
|
194
|
+
const { body, columns } = buildChartRequest(state, select);
|
|
195
|
+
const res = await fetch(`${dsUrl(datasourceId)}/chart/data`, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: {
|
|
198
|
+
"Content-Type": "application/json",
|
|
199
|
+
"X-User-Timezone": readUserTimezone() ?? "UTC",
|
|
200
|
+
Authorization: `Bearer ${readSessionToken()}`
|
|
201
|
+
},
|
|
202
|
+
body: JSON.stringify(body)
|
|
203
|
+
});
|
|
204
|
+
if (!res.ok) throw new Error(`Softr datasource chart failed for "${datasourceId}": ${res.status}`);
|
|
205
|
+
const payload = await res.json();
|
|
206
|
+
return {
|
|
207
|
+
data: mapChartRows(columns, payload.data),
|
|
208
|
+
executionTimestamp: /* @__PURE__ */ new Date(payload.executionTimestamp * 1e3)
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Aggregates records into one value, with no grouping.
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* const { value } = await ds
|
|
216
|
+
* .metric(sales, (q) => q.amount.sum)
|
|
217
|
+
* .where((q) => q.status.is("paid"))
|
|
218
|
+
* // value: number | null
|
|
219
|
+
*/
|
|
220
|
+
function metric(query, pick) {
|
|
221
|
+
const { select } = resolveTarget(query, "read");
|
|
222
|
+
const aliases = Object.keys(select);
|
|
223
|
+
const picked = pick(makeMeasureSet(select));
|
|
224
|
+
assert(isHandle(picked, "MEASURE"), "a metric must come from the callback a metric hands you");
|
|
225
|
+
const handle = picked;
|
|
226
|
+
const filters = [];
|
|
227
|
+
let result;
|
|
228
|
+
const run = () => result ??= executeMetric(query, handle, filters);
|
|
229
|
+
const builder = {
|
|
230
|
+
[Symbol.toStringTag]: "MetricQuery",
|
|
231
|
+
where(build) {
|
|
232
|
+
filters.push(build(makeFieldSet(aliases)));
|
|
233
|
+
return builder;
|
|
234
|
+
},
|
|
235
|
+
then(onfulfilled, onrejected) {
|
|
236
|
+
return run().then(onfulfilled, onrejected);
|
|
237
|
+
},
|
|
238
|
+
catch(onrejected) {
|
|
239
|
+
return run().catch(onrejected);
|
|
240
|
+
},
|
|
241
|
+
finally(onfinally) {
|
|
242
|
+
return run().finally(onfinally);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
return builder;
|
|
246
|
+
}
|
|
247
|
+
function buildMetricRequest(handle, filters, select) {
|
|
248
|
+
return {
|
|
249
|
+
body: {
|
|
250
|
+
groupBy: [],
|
|
251
|
+
metrics: [{
|
|
252
|
+
field: handle.fieldId ?? void 0,
|
|
253
|
+
aggregation: handle.aggregation
|
|
254
|
+
}],
|
|
255
|
+
filterCriteria: toFilterCriteria(filters, select)
|
|
256
|
+
},
|
|
257
|
+
responseKey: handle.aggregation === "COUNT" && !handle.fieldId ? COUNT_CNT : handle.fieldId
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function readMetricValue(responseKey, data) {
|
|
261
|
+
const row = data.find((item) => responseKey in item);
|
|
262
|
+
assert(row, "the response has no value for the metric");
|
|
263
|
+
return row[responseKey];
|
|
264
|
+
}
|
|
265
|
+
async function executeMetric(query, handle, filters) {
|
|
266
|
+
const { datasourceId, select } = resolveTarget(query, "read");
|
|
267
|
+
const { body, responseKey } = buildMetricRequest(handle, filters, select);
|
|
268
|
+
const res = await fetch(`${dsUrl(datasourceId)}/chart/data`, {
|
|
269
|
+
method: "POST",
|
|
270
|
+
headers: {
|
|
271
|
+
"Content-Type": "application/json",
|
|
272
|
+
"X-User-Timezone": readUserTimezone() ?? "UTC",
|
|
273
|
+
Authorization: `Bearer ${readSessionToken()}`
|
|
274
|
+
},
|
|
275
|
+
body: JSON.stringify(body)
|
|
276
|
+
});
|
|
277
|
+
if (!res.ok) throw new Error(`Softr datasource metric failed for "${datasourceId}": ${res.status}`);
|
|
278
|
+
const payload = await res.json();
|
|
279
|
+
return {
|
|
280
|
+
value: readMetricValue(responseKey, payload.data),
|
|
281
|
+
executionTimestamp: /* @__PURE__ */ new Date(payload.executionTimestamp * 1e3)
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function toFilterCriteria(filters, select) {
|
|
285
|
+
return filters.length ? dealiasFilterCriteria(buildFilterCriteria(andAll(filters)), select) : {};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
//#endregion
|
|
289
|
+
export { chart, metric };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createRecord, deleteRecord, getRecord, listRecords, updateRecord } from "./record.js";
|
|
2
|
+
import { chart, metric } from "./chart.js";
|
|
3
|
+
import "@tanstack/react-start/server-only";
|
|
4
|
+
|
|
5
|
+
//#region src/datasource/datasource.d.ts
|
|
6
|
+
declare const ds: {
|
|
7
|
+
list: typeof listRecords;
|
|
8
|
+
get: typeof getRecord;
|
|
9
|
+
create: typeof createRecord;
|
|
10
|
+
update: typeof updateRecord;
|
|
11
|
+
delete: typeof deleteRecord;
|
|
12
|
+
metric: typeof metric;
|
|
13
|
+
chart: typeof chart;
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
export { ds };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createRecord, deleteRecord, getRecord, listRecords, updateRecord } from "./record.js";
|
|
2
|
+
import { chart, metric } from "./chart.js";
|
|
3
|
+
import "@tanstack/react-start/server-only";
|
|
4
|
+
|
|
5
|
+
//#region src/datasource/datasource.ts
|
|
6
|
+
const ds = {
|
|
7
|
+
list: listRecords,
|
|
8
|
+
get: getRecord,
|
|
9
|
+
create: createRecord,
|
|
10
|
+
update: updateRecord,
|
|
11
|
+
delete: deleteRecord,
|
|
12
|
+
metric,
|
|
13
|
+
chart
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
//#endregion
|
|
17
|
+
export { ds };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { FieldTypeOf, ReadOf, ReadShape, SoftrDatasources } from "../action.js";
|
|
2
|
+
import { AnyConstructable, ConstructableFilter, ConstructableFilterGroup, ConstructableOrderBy } from "./filter.js";
|
|
3
|
+
|
|
4
|
+
//#region src/datasource/fields.d.ts
|
|
5
|
+
type EmptyChain = {
|
|
6
|
+
isEmpty(): ConstructableFilter;
|
|
7
|
+
isNotEmpty(): ConstructableFilter;
|
|
8
|
+
};
|
|
9
|
+
type BooleanChain = EmptyChain & {
|
|
10
|
+
is(value: boolean): ConstructableFilter;
|
|
11
|
+
isNot(value: boolean): ConstructableFilter;
|
|
12
|
+
};
|
|
13
|
+
type NumberChain = EmptyChain & {
|
|
14
|
+
is(value: number): ConstructableFilter;
|
|
15
|
+
isNot(value: number): ConstructableFilter;
|
|
16
|
+
gt(value: number): ConstructableFilter;
|
|
17
|
+
gte(value: number): ConstructableFilter;
|
|
18
|
+
lt(value: number): ConstructableFilter;
|
|
19
|
+
lte(value: number): ConstructableFilter;
|
|
20
|
+
between(start: number, end: number): ConstructableFilter;
|
|
21
|
+
};
|
|
22
|
+
type ArrayChain<TMember$1 extends string> = EmptyChain & {
|
|
23
|
+
is(values: Array<TMember$1>): ConstructableFilter;
|
|
24
|
+
isOneOf(values: Array<TMember$1>): ConstructableFilter;
|
|
25
|
+
isNoneOf(values: Array<TMember$1>): ConstructableFilter;
|
|
26
|
+
hasAllOf(values: Array<TMember$1>): ConstructableFilter;
|
|
27
|
+
};
|
|
28
|
+
type ChoiceChain<TId$1 extends string> = EmptyChain & {
|
|
29
|
+
is(value: TId$1): ConstructableFilter;
|
|
30
|
+
isNot(value: TId$1): ConstructableFilter;
|
|
31
|
+
isOneOf(values: Array<TId$1>): ConstructableFilter;
|
|
32
|
+
isNoneOf(values: Array<TId$1>): ConstructableFilter;
|
|
33
|
+
};
|
|
34
|
+
/** What DSv2 filters generically: matched whole or scanned, never against a set. */
|
|
35
|
+
type GenericChain = EmptyChain & {
|
|
36
|
+
is(value: string): ConstructableFilter;
|
|
37
|
+
isNot(value: string): ConstructableFilter;
|
|
38
|
+
contains(value: string): ConstructableFilter;
|
|
39
|
+
};
|
|
40
|
+
type TextChain<TValue extends string> = EmptyChain & {
|
|
41
|
+
is(value: TValue): ConstructableFilter;
|
|
42
|
+
isNot(value: TValue): ConstructableFilter;
|
|
43
|
+
contains(value: string): ConstructableFilter;
|
|
44
|
+
startsWith(value: string): ConstructableFilter;
|
|
45
|
+
endsWith(value: string): ConstructableFilter;
|
|
46
|
+
isOneOf(values: Array<string>): ConstructableFilter;
|
|
47
|
+
isNoneOf(values: Array<string>): ConstructableFilter;
|
|
48
|
+
};
|
|
49
|
+
type DateChain = EmptyChain & {
|
|
50
|
+
is(value: string): ConstructableFilter;
|
|
51
|
+
isNot(value: string): ConstructableFilter;
|
|
52
|
+
gt(value: string): ConstructableFilter;
|
|
53
|
+
gte(value: string): ConstructableFilter;
|
|
54
|
+
lt(value: string): ConstructableFilter;
|
|
55
|
+
lte(value: string): ConstructableFilter;
|
|
56
|
+
between(start: string, end: string): ConstructableFilter;
|
|
57
|
+
isNotBetween(start: string, end: string): ConstructableFilter;
|
|
58
|
+
};
|
|
59
|
+
/** Every operator, for a field whose type nothing has generated yet. */
|
|
60
|
+
type AnyChain = EmptyChain & {
|
|
61
|
+
is(value: unknown): ConstructableFilter;
|
|
62
|
+
isNot(value: unknown): ConstructableFilter;
|
|
63
|
+
contains(value: string): ConstructableFilter;
|
|
64
|
+
startsWith(value: string): ConstructableFilter;
|
|
65
|
+
endsWith(value: string): ConstructableFilter;
|
|
66
|
+
isOneOf(values: Array<string>): ConstructableFilter;
|
|
67
|
+
isNoneOf(values: Array<string>): ConstructableFilter;
|
|
68
|
+
hasAllOf(values: Array<string>): ConstructableFilter;
|
|
69
|
+
gt(value: number | string): ConstructableFilter;
|
|
70
|
+
gte(value: number | string): ConstructableFilter;
|
|
71
|
+
lt(value: number | string): ConstructableFilter;
|
|
72
|
+
lte(value: number | string): ConstructableFilter;
|
|
73
|
+
between(start: number | string, end: number | string): ConstructableFilter;
|
|
74
|
+
isNotBetween(start: number | string, end: number | string): ConstructableFilter;
|
|
75
|
+
};
|
|
76
|
+
type IdOf<TValue> = TValue extends {
|
|
77
|
+
id: infer TId extends string;
|
|
78
|
+
} ? TId : string;
|
|
79
|
+
/**
|
|
80
|
+
* What a choice-shaped field compares against: its own ids where codegen read the choice
|
|
81
|
+
* list, and any string where it did not. A linked record is the open case, and it is the
|
|
82
|
+
* only thing distinguishing one from a select at the type level.
|
|
83
|
+
*/
|
|
84
|
+
type ChoiceIds<TValue> = string extends IdOf<TValue> ? string : IdOf<TValue>;
|
|
85
|
+
type TextFieldType = 'SINGLE_LINE_TEXT' | 'LONG_TEXT' | 'URL' | 'EMAIL' | 'PHONE' | 'ADDRESS' | 'RECORD_ID' | 'LOCATION' | 'TIME' | 'BUTTON';
|
|
86
|
+
type NumberFieldType = 'NUMBER' | 'PERCENT' | 'CURRENCY' | 'PROGRESS' | 'RATING' | 'DURATION' | 'COUNT' | 'AUTONUMBER';
|
|
87
|
+
type DateFieldType = 'DATE' | 'DATETIME' | 'CREATED_AT' | 'UPDATED_AT';
|
|
88
|
+
type ChoiceFieldType = 'SELECT' | 'LINKED_RECORD' | 'CREATED_BY' | 'UPDATED_BY';
|
|
89
|
+
/** The types with no single value to compare: only whether the field holds one. */
|
|
90
|
+
type EmptyFieldType = 'ATTACHMENT' | 'DATE_RANGE';
|
|
91
|
+
type ElementOf<TValue> = TValue extends Array<infer TMember> ? TMember : TValue;
|
|
92
|
+
/**
|
|
93
|
+
* The operators one alias offers, chosen from the type codegen declared for the field rather
|
|
94
|
+
* than from what it reads as: a `DATETIME` and a text field both read as `string`, and only
|
|
95
|
+
* the declared type tells them apart.
|
|
96
|
+
*
|
|
97
|
+
* `TValue` is still needed for the literal choice ids, which live on the read type and
|
|
98
|
+
* nowhere else. A field with no declared type lands on the permissive chain rather than on
|
|
99
|
+
* none.
|
|
100
|
+
*/
|
|
101
|
+
type ChainFor<TFieldType, TValue> = [TFieldType] extends [never] ? AnyChain : TFieldType extends `${infer TBase}[]` ? TBase extends EmptyFieldType ? EmptyChain : ArrayChain<ChoiceIds<ElementOf<TValue>>> : TFieldType extends TextFieldType ? TextChain<TValue & string> : TFieldType extends NumberFieldType ? NumberChain : TFieldType extends 'USER' ? GenericChain : TFieldType extends 'CHECKBOX' ? BooleanChain : TFieldType extends DateFieldType ? DateChain : TFieldType extends ChoiceFieldType ? ChoiceChain<ChoiceIds<TValue>> : TFieldType extends EmptyFieldType ? EmptyChain : AnyChain;
|
|
102
|
+
type Combinators = {
|
|
103
|
+
$and(...members: Array<AnyConstructable>): ConstructableFilterGroup;
|
|
104
|
+
$or(...members: Array<AnyConstructable>): ConstructableFilterGroup;
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* What a `where` callback is handed: every alias the action declares, already a chain of the
|
|
108
|
+
* operators that alias accepts, plus `$and` and `$or` to nest them.
|
|
109
|
+
*
|
|
110
|
+
* Keyed off the action's own `fields` map, so an alias resolves to a field id and from there
|
|
111
|
+
* to the declared type. With nothing generated there is no field id to resolve, and an alias
|
|
112
|
+
* the action never declared has to resolve too, so that case is open by name and permissive
|
|
113
|
+
* by operator.
|
|
114
|
+
*/
|
|
115
|
+
type FieldSet<TShape extends ReadShape> = keyof SoftrDatasources extends never ? Record<string, AnyChain> & Combinators : { [Alias in keyof TShape['fields']]: ChainFor<FieldTypeOf<TShape['datasource'], TShape['fields'][Alias]>, NonNullable<ReadOf<TShape['datasource']>[TShape['fields'][Alias] & keyof ReadOf<TShape['datasource']>]>> } & Combinators;
|
|
116
|
+
/** What an `orderBy` callback is handed: two handles per alias and nothing else. */
|
|
117
|
+
type SortSet<TRecord> = { [K in keyof TRecord]: {
|
|
118
|
+
readonly asc: ConstructableOrderBy;
|
|
119
|
+
readonly desc: ConstructableOrderBy;
|
|
120
|
+
} };
|
|
121
|
+
//#endregion
|
|
122
|
+
export { DateFieldType, FieldSet, NumberFieldType, SortSet };
|