@softr/sdk 0.0.25 → 0.0.26-alpha-0
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 +23 -0
- package/dist/codegen/datasource-types.js +471 -0
- package/dist/codegen/fetch-metadata.js +46 -0
- package/dist/codegen/softr-env.js +102 -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 +31 -8
- package/skills/softr-sdk/SKILL.md +9 -5
- package/skills/softr-sdk/charts-and-metrics/SKILL.md +178 -0
- package/skills/softr-sdk/charts-and-metrics/references/aggregations.md +76 -0
- package/skills/softr-sdk/data-mutations/SKILL.md +75 -55
- package/skills/softr-sdk/reading-records/SKILL.md +76 -92
- package/skills/softr-sdk/reading-records/references/field-types.md +41 -48
- package/skills/softr-sdk/reading-records/references/query-builder.md +66 -70
- 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
|
@@ -139,4 +139,4 @@ type VisibilityHandle<R extends RouteVisibility = RouteVisibility> = {
|
|
|
139
139
|
*/
|
|
140
140
|
declare function defineVisibility<const R extends RouteVisibility>(rule: R): VisibilityHandle<R>;
|
|
141
141
|
//#endregion
|
|
142
|
-
export {
|
|
142
|
+
export { defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError };
|
package/dist/server.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { auth } from "./auth/server.js";
|
|
2
|
-
import { ds } from "./
|
|
2
|
+
import { ds } from "./datasource/datasource.js";
|
|
3
3
|
import { workflow } from "./core/workflow.js";
|
|
4
4
|
import { integration } from "./core/integration.js";
|
|
5
5
|
import "@tanstack/react-start/server-only";
|
package/dist/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { auth } from "./auth/server.js";
|
|
2
|
-
import { ds } from "./
|
|
2
|
+
import { ds } from "./datasource/datasource.js";
|
|
3
3
|
import { workflow } from "./core/workflow.js";
|
|
4
4
|
import { integration } from "./core/integration.js";
|
|
5
5
|
import "@tanstack/react-start/server-only";
|
package/package.json
CHANGED
|
@@ -1,15 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softr/sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.26-alpha-0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
|
-
".": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"import": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"./client": {
|
|
11
|
+
"types": "./dist/client.d.ts",
|
|
12
|
+
"import": "./dist/client.js"
|
|
13
|
+
},
|
|
14
|
+
"./server": {
|
|
15
|
+
"types": "./dist/server.d.ts",
|
|
16
|
+
"import": "./dist/server.js"
|
|
17
|
+
},
|
|
18
|
+
"./vite": {
|
|
19
|
+
"types": "./dist/vite.d.ts",
|
|
20
|
+
"import": "./dist/vite.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"softr-sdk": "./dist/cli.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"skills",
|
|
29
|
+
"!skills/_artifacts"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
10
33
|
},
|
|
11
|
-
"files": ["dist", "skills", "!skills/_artifacts"],
|
|
12
|
-
"publishConfig": { "access": "public" },
|
|
13
34
|
"scripts": {
|
|
14
35
|
"build": "tsdown",
|
|
15
36
|
"test": "node --import ./src/test-utils/setup.ts --test",
|
|
@@ -41,7 +62,9 @@
|
|
|
41
62
|
"optional": true
|
|
42
63
|
}
|
|
43
64
|
},
|
|
44
|
-
"keywords": [
|
|
65
|
+
"keywords": [
|
|
66
|
+
"tanstack-intent"
|
|
67
|
+
],
|
|
45
68
|
"intent": {
|
|
46
69
|
"version": 1,
|
|
47
70
|
"repo": "@softr/sdk",
|
|
@@ -5,9 +5,9 @@ description: >-
|
|
|
5
5
|
four entrypoints (@softr/sdk, /server, /client, /vite) and the client-server
|
|
6
6
|
boundary between them, softrAuthMiddleware, getSoftrContext,
|
|
7
7
|
initialSoftrContext, SoftrContext, and which sub-skill to load for route
|
|
8
|
-
visibility, the current user, reading records,
|
|
9
|
-
this when unsure which sub-skill applies, or when
|
|
10
|
-
sub-skill stands alone for its own task.
|
|
8
|
+
visibility, the current user, reading records, charts and metrics, writing
|
|
9
|
+
records, or auth. Load this when unsure which sub-skill applies, or when
|
|
10
|
+
touching the wiring; each sub-skill stands alone for its own task.
|
|
11
11
|
metadata:
|
|
12
12
|
type: core
|
|
13
13
|
library: softr-sdk
|
|
@@ -21,7 +21,7 @@ metadata:
|
|
|
21
21
|
> **CRITICAL**: The SDK is split across four entrypoints and the split is enforced. `ds` and `auth` exist only on `@softr/sdk/server` and only run inside a `createServerFn` handler. Importing them anywhere else fails the build.
|
|
22
22
|
> **CRITICAL**: Every route file must declare its audience with `defineVisibility`. There is no default and nothing is inherited from a sibling. A route file without a declaration is a lint error.
|
|
23
23
|
> **CRITICAL**: The app is already wired. `src/start.ts`, `src/router.tsx` and `src/routes/__root.tsx` register the middleware and resolve the session. Do not add a second registration.
|
|
24
|
-
> **CRITICAL**: `workflow.trigger`
|
|
24
|
+
> **CRITICAL**: `workflow.trigger` and `integration.fetch` are exploratory. They exist on the entrypoints below and they typecheck, but their endpoints are not final. Do not build a feature on either of them. Tell the user the capability is not ready and ask how they want to proceed.
|
|
25
25
|
|
|
26
26
|
Each sub-skill stands alone for its task and repeats the boundary above, so most work needs **one** sub-skill and not this file. Load this one when you are unsure which sub-skill applies, or when the wiring below is what you are touching. Add a second sub-skill only when the work genuinely crosses that boundary: a page that lists records behind a group restriction needs `reading-records` plus `routes-and-visibility`.
|
|
27
27
|
|
|
@@ -29,7 +29,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
|
|
|
29
29
|
|
|
30
30
|
| Import | Runs | Holds |
|
|
31
31
|
| --- | --- | --- |
|
|
32
|
-
| `@softr/sdk` | Both | `
|
|
32
|
+
| `@softr/sdk` | Both | `defineAction`, `defineVisibility`, `useCurrentUser`, `usePermissions`, `useUserTimezone`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
|
|
33
33
|
| `@softr/sdk/server` | Server only | `ds`, `auth`, `workflow`, `integration` |
|
|
34
34
|
| `@softr/sdk/client` | Browser only | Nothing yet, reserved for browser-only utilities |
|
|
35
35
|
| `@softr/sdk/vite` | Build | `softr()`, already in `vite.config.ts` |
|
|
@@ -41,6 +41,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
|
|
|
41
41
|
| Add a route, or change who can see one | [routes-and-visibility/SKILL.md](./routes-and-visibility/SKILL.md) |
|
|
42
42
|
| Read the signed-in visitor or what they may do | [current-user/SKILL.md](./current-user/SKILL.md) |
|
|
43
43
|
| Fetch records from a datasource | [reading-records/SKILL.md](./reading-records/SKILL.md) |
|
|
44
|
+
| Chart, aggregate or count records | [charts-and-metrics/SKILL.md](./charts-and-metrics/SKILL.md) |
|
|
44
45
|
| Create, update or delete a record | [data-mutations/SKILL.md](./data-mutations/SKILL.md) |
|
|
45
46
|
| Sign a visitor in, out, or up | [auth/SKILL.md](./auth/SKILL.md) |
|
|
46
47
|
|
|
@@ -56,6 +57,9 @@ Showing the visitor's name, or hiding UI they cannot use?
|
|
|
56
57
|
Putting records on a page, in a table, a list or a detail view?
|
|
57
58
|
load softr-sdk/reading-records
|
|
58
59
|
|
|
60
|
+
Drawing a chart, a stat tile, or any aggregated number?
|
|
61
|
+
load softr-sdk/charts-and-metrics
|
|
62
|
+
|
|
59
63
|
Submitting a form, editing a record, or deleting one?
|
|
60
64
|
load softr-sdk/data-mutations
|
|
61
65
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: charts-and-metrics
|
|
3
|
+
description: >-
|
|
4
|
+
Aggregate Softr datasource records inside a server function: ds.chart with
|
|
5
|
+
groupBy, metrics, where and orderBy for chart rows, ds.metric for a single
|
|
6
|
+
headline number, date bucket intervals, which aggregations each kind of field
|
|
7
|
+
offers, the two-group-column ceiling, and distinct values for filter
|
|
8
|
+
controls. Aggregation only. Listing and fetching records is reading-records.
|
|
9
|
+
metadata:
|
|
10
|
+
type: sub-skill
|
|
11
|
+
library: softr-sdk
|
|
12
|
+
library_version: 0.0.22
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
This skill builds on softr-sdk, and on reading-records for declaring the action a chart aggregates over.
|
|
16
|
+
|
|
17
|
+
# Charts and metrics
|
|
18
|
+
|
|
19
|
+
`ds.chart` groups records and aggregates each group into one row: the data behind a bar chart, a line, a pie. `ds.metric` aggregates everything into one value: the number on a stat tile. Both live on `@softr/sdk/server` and run inside a `createServerFn` handler, and both take the same read action `ds.list` takes, with the same `where` filters.
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
25
|
+
import { defineAction } from '@softr/sdk'
|
|
26
|
+
import { ds } from '@softr/sdk/server'
|
|
27
|
+
|
|
28
|
+
const sales = defineAction({
|
|
29
|
+
id: 'sales',
|
|
30
|
+
datasource: '8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77',
|
|
31
|
+
operation: 'read',
|
|
32
|
+
fields: {
|
|
33
|
+
amount: 'Vk9cXm2pSz6Ld4', // CURRENCY
|
|
34
|
+
region: 'fZk3Qm9pLw2Xa1', // SELECT
|
|
35
|
+
status: 'Ru7bVx2cNq0Ed9', // SELECT, choice ids 'paid' | 'open' | 'void'
|
|
36
|
+
closedAt: 'Xp2dLh6vKz9Fa3', // DATETIME
|
|
37
|
+
rep: 'Gq5wNt8rJy3Hb1', // LINKED_RECORD
|
|
38
|
+
},
|
|
39
|
+
permission: { userGroup: 'LOGGED_IN_USERS' },
|
|
40
|
+
})
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
A chart only sees the action's declared fields, so a field to group, aggregate or filter by must be in `fields`.
|
|
44
|
+
|
|
45
|
+
## Core Patterns
|
|
46
|
+
|
|
47
|
+
### A bar chart
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const ordersByRegion = createServerFn().handler(async () => {
|
|
51
|
+
const { data } = await ds
|
|
52
|
+
.chart(sales)
|
|
53
|
+
.groupBy((q) => ({ region: q.region }))
|
|
54
|
+
.metrics((q) => ({ orders: q.$count }))
|
|
55
|
+
.orderBy((q) => q.orders.desc)
|
|
56
|
+
return data
|
|
57
|
+
})
|
|
58
|
+
// data: Array<{ region: string | null; orders: number }>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
One row per group, keyed by the names you chose in `groupBy` and `metrics`. `orders` is `number`, not `number | null`: a group that exists holds at least one record. The `null` region is the bucket of records with no value. The result also carries `executionTimestamp: Date`, when the numbers were computed.
|
|
62
|
+
|
|
63
|
+
A SELECT or LINKED_RECORD group column comes back as the choice's display label, not its id. Labels are user-editable and a `where` on the same field compares ids, so a clicked bar's value cannot be fed straight back into a filter.
|
|
64
|
+
|
|
65
|
+
### A time series
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const { data } = await ds
|
|
69
|
+
.chart(sales)
|
|
70
|
+
.groupBy((q) => ({ month: q.closedAt.byMonth }))
|
|
71
|
+
.metrics((q) => ({ revenue: q.amount.sum }))
|
|
72
|
+
.where((q) => q.status.is('paid'))
|
|
73
|
+
.orderBy((q) => q.month.asc)
|
|
74
|
+
// data: Array<{ month: string | null; revenue: number | null }>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`q.closedAt` bare groups by the field's value. The bucket leaves hanging off it group a date field by the bucket: `byDay`, `byWeek`, `byMonth`, `byQuarter`, `byYear`, and the cyclic `byHourOfDay`, `byDayOfWeek`, `byMonthOfYear`, `byQuarterOfYear`. What each one holds is in [references/aggregations.md](./references/aggregations.md); `byMonth` reads as `"2025-06"`.
|
|
78
|
+
|
|
79
|
+
**Sort the time axis ascending yourself.** Left unsorted, rows come back by the first bucket ascending when there is one, else by the first metric descending. A time axis that misses the fallback arrives in value order, which draws a scrambled line rather than failing.
|
|
80
|
+
|
|
81
|
+
### Spread, not just totals
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
const { data } = await ds
|
|
85
|
+
.chart(sales)
|
|
86
|
+
.groupBy((q) => ({ region: q.region }))
|
|
87
|
+
.metrics((q) => ({
|
|
88
|
+
smallest: q.amount.min,
|
|
89
|
+
typical: q.amount.avg,
|
|
90
|
+
biggest: q.amount.max,
|
|
91
|
+
}))
|
|
92
|
+
.orderBy((q) => q.typical.desc)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Any number of metrics in one request, several over the same field included. Each row carries all of them, so one request can feed two panels. A metric key may not reuse a group key, and two metrics may not ask for the same value. `avg` comes back rounded to two decimals.
|
|
96
|
+
|
|
97
|
+
### Two group columns, which is the ceiling
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const { data } = await ds
|
|
101
|
+
.chart(sales)
|
|
102
|
+
.groupBy((q) => ({ month: q.closedAt.byMonth, region: q.region }))
|
|
103
|
+
.metrics((q) => ({ revenue: q.amount.sum }))
|
|
104
|
+
.orderBy((q) => [q.month.asc, q.revenue.desc])
|
|
105
|
+
// data: Array<{ month: string | null; region: string | null; revenue: number | null }>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The result is long format, one row per month and region pair, so the app pivots into one array per series before charting. **A chart groups by at most two columns**, each a different field, and a field cannot be grouped and aggregated in the same chart. A third group column is a compile error.
|
|
109
|
+
|
|
110
|
+
### Distinct values, for a filter control
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const { data } = await ds.chart(sales).groupBy((q) => ({ region: q.region }))
|
|
114
|
+
// data: Array<{ region: string | null }>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Skip `metrics` entirely and the rows hold the distinct group values, one each. This is the cheapest way to fill a dropdown from a real column.
|
|
118
|
+
|
|
119
|
+
### A headline number
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const { value } = await ds
|
|
123
|
+
.metric(sales, (q) => q.amount.sum)
|
|
124
|
+
.where((q) => q.status.is('paid'))
|
|
125
|
+
// value: number | null
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
One aggregation, no grouping, one value. Whenever the answer is a single number, a stat tile or a count, reach for `ds.metric` rather than a one-row chart: there is nothing to group by and nothing to unpack out of `data`. The same measure handles apply, `q.$count` included:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
const { value: open } = await ds.metric(sales, (q) => q.$count).where((q) => q.status.is('open'))
|
|
132
|
+
// open: number
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Top N
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
const { data } = await ds
|
|
139
|
+
.chart(sales)
|
|
140
|
+
.groupBy((q) => ({ rep: q.rep }))
|
|
141
|
+
.metrics((q) => ({ revenue: q.amount.sum }))
|
|
142
|
+
.orderBy((q) => q.revenue.desc)
|
|
143
|
+
|
|
144
|
+
const top5 = data.slice(0, 5)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
A chart has no row limit to set, so a top-N is a client-side slice of the fully sorted rows.
|
|
148
|
+
|
|
149
|
+
### A dashboard load
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
const loadDashboard = createServerFn().handler(async () => {
|
|
153
|
+
const [mtd, series] = await Promise.all([
|
|
154
|
+
ds
|
|
155
|
+
.metric(sales, (q) => q.amount.sum)
|
|
156
|
+
.where((q) => q.$and(q.status.is('paid'), q.closedAt.gte('2026-09-01'))),
|
|
157
|
+
ds
|
|
158
|
+
.chart(sales)
|
|
159
|
+
.groupBy((q) => ({ month: q.closedAt.byMonth }))
|
|
160
|
+
.metrics((q) => ({ revenue: q.amount.sum }))
|
|
161
|
+
.where((q) => q.status.is('paid'))
|
|
162
|
+
.orderBy((q) => q.month.asc),
|
|
163
|
+
])
|
|
164
|
+
return { mtd: mtd.value, series: series.data }
|
|
165
|
+
})
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Each tile and panel is its own request, fired in parallel from one handler.
|
|
169
|
+
|
|
170
|
+
## Order of calls
|
|
171
|
+
|
|
172
|
+
`groupBy` comes first and exactly once; a chart cannot be awaited before it. `metrics` follows, at most once, or not at all for distinct values. `where` can be chained anywhere, repeatedly, and calling it more than once ands the filters together. `orderBy` sorts by the chart's own keys, group and metric alike, and only sees the keys declared so far, so call it last.
|
|
173
|
+
|
|
174
|
+
## References
|
|
175
|
+
|
|
176
|
+
- [references/aggregations.md](./references/aggregations.md): every bucket interval and its value type, which aggregations each kind of field offers, and the chart rules
|
|
177
|
+
- [reading-records/SKILL.md](../reading-records/SKILL.md): declaring the action, and the filter operators `where` accepts
|
|
178
|
+
- [reading-records/references/query-builder.md](../reading-records/references/query-builder.md): the full filter matrix
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Buckets, aggregations and chart rules
|
|
2
|
+
|
|
3
|
+
## Group columns
|
|
4
|
+
|
|
5
|
+
A bare handle in `groupBy` groups by the field's value. What the column reads back as depends on the field's declared type:
|
|
6
|
+
|
|
7
|
+
| Field type | Column value |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| SELECT, LINKED_RECORD, CREATED_BY, UPDATED_BY | `string \| null`, the display label, not the id |
|
|
10
|
+
| Text fields | `string \| null` |
|
|
11
|
+
| Number fields | `number \| null` |
|
|
12
|
+
| CHECKBOX | `boolean \| null` |
|
|
13
|
+
| DATE, DATETIME, CREATED_AT, UPDATED_AT | `string \| null`, or a bucket leaf below |
|
|
14
|
+
| Multi-value (`SELECT[]` and friends), ATTACHMENT, DATE_RANGE | Cannot group |
|
|
15
|
+
|
|
16
|
+
`null` is always the bucket of records with no value. A multi-value field can neither group nor aggregate: exploding it would count each record once per value and inflate every metric.
|
|
17
|
+
|
|
18
|
+
## Date buckets
|
|
19
|
+
|
|
20
|
+
Leaves on every date and datetime handle, `q.closedAt.byMonth` style:
|
|
21
|
+
|
|
22
|
+
| Leaf | Groups by | Column value |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| `.byDay` | Calendar day | `"2025-06-14"` |
|
|
25
|
+
| `.byWeek` | Calendar week | The ISO date of the week's Monday |
|
|
26
|
+
| `.byMonth` | Calendar month | `"2025-06"` |
|
|
27
|
+
| `.byQuarter` | Calendar quarter | `"Q2 2025"`, a display string, not a parseable date |
|
|
28
|
+
| `.byYear` | Calendar year | The year as a `number` |
|
|
29
|
+
| `.byHourOfDay` | Hour of day | `0`–`23`. DATETIME fields only, not DATE |
|
|
30
|
+
| `.byDayOfWeek` | Day of week | `1`–`7`, 1 is Sunday |
|
|
31
|
+
| `.byMonthOfYear` | Month of year | `1`–`12` |
|
|
32
|
+
| `.byQuarterOfYear` | Quarter of year | `1`–`4` |
|
|
33
|
+
|
|
34
|
+
Every bucket value is `| null` for the empty bucket. The cyclic buckets come back as bare numbers; the app supplies the labels.
|
|
35
|
+
|
|
36
|
+
`byWeek` is Monday-based while `byDayOfWeek` counts from Sunday, so the two cannot be lined up by arithmetic alone.
|
|
37
|
+
|
|
38
|
+
## Aggregations
|
|
39
|
+
|
|
40
|
+
Handles in `metrics`, and the second argument of `ds.metric`:
|
|
41
|
+
|
|
42
|
+
| Handle | Available on | Comes back as |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| `.sum` | Number fields | `number \| null` |
|
|
45
|
+
| `.avg` | Number fields | `number \| null`, rounded to two decimals |
|
|
46
|
+
| `.min` / `.max` | Number fields | `number \| null` |
|
|
47
|
+
| `.min` / `.max` | Date fields | `string \| null`, as `"2025-06-14"` |
|
|
48
|
+
| `.distinct` | Number and date fields | `number`, the count of distinct values |
|
|
49
|
+
| `q.$count` | No field: counts rows | `number` |
|
|
50
|
+
|
|
51
|
+
Number fields: NUMBER, PERCENT, CURRENCY, PROGRESS, RATING, DURATION, COUNT, AUTONUMBER. Date fields: DATE, DATETIME, CREATED_AT, UPDATED_AT.
|
|
52
|
+
|
|
53
|
+
Every other field offers no aggregations at all. `q.$count` is the only measure that touches no field, so a text or choice column is measured by grouping on it and counting rows.
|
|
54
|
+
|
|
55
|
+
`sum`, `avg`, `min` and `max` are `null` for a group with no values. `$count` and `distinct` are plain `number`.
|
|
56
|
+
|
|
57
|
+
## Rules
|
|
58
|
+
|
|
59
|
+
- `groupBy` takes one or two columns, is required, and is called once. A third column, and a chart with no group columns, are compile errors.
|
|
60
|
+
- Each group column is a different field, and a field cannot be grouped and aggregated in the same chart.
|
|
61
|
+
- `metrics` is optional and called at most once. A metric key may not reuse a group key, and two metrics may not ask for the same aggregation of the same field.
|
|
62
|
+
- `orderBy` names the chart's own keys, group and metric alike. Call it last: it only sees keys already declared.
|
|
63
|
+
- Left unsorted, rows come back by the first bucket column ascending; with no bucket, by the first metric descending; with neither, by the first group ascending.
|
|
64
|
+
- There is no row limit. A top-N is a client-side `slice` of the sorted rows.
|
|
65
|
+
|
|
66
|
+
## Errors
|
|
67
|
+
|
|
68
|
+
| Message | Cause |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| `a chart needs at least one column to group by` | Awaiting a chart whose `groupBy` produced no columns |
|
|
71
|
+
| `a chart may group by at most two columns` | Three or more group columns, on an untyped action |
|
|
72
|
+
| `a chart may group by a field only once` | The same field twice in `groupBy` |
|
|
73
|
+
| `a field cannot be grouped and aggregated in one chart` | One field in both `groupBy` and `metrics` |
|
|
74
|
+
| `two metrics ask for the same value` | The same aggregation of the same field under two keys |
|
|
75
|
+
| `"..." is not a column of this chart` | An `orderBy` key that is neither a group nor a metric key |
|
|
76
|
+
| `a chart column must come from the callback a chart hands you` | A hand-built object where a handle belongs |
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
name: data-mutations
|
|
3
3
|
description: >-
|
|
4
4
|
Create, update and delete records in a Softr datasource from inside a server
|
|
5
|
-
function: ds.create, ds.update and
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
function: declaring a write with defineAction, ds.create, ds.update and
|
|
6
|
+
ds.delete from @softr/sdk/server, the data payload keyed by the action's
|
|
7
|
+
aliases, what each field type accepts on a write, and the record each call
|
|
8
|
+
returns. Writes only. Reads, charts and metrics are not covered.
|
|
8
9
|
metadata:
|
|
9
10
|
type: sub-skill
|
|
10
11
|
library: softr-sdk
|
|
@@ -22,93 +23,101 @@ metadata:
|
|
|
22
23
|
```ts
|
|
23
24
|
import { createServerFn } from '@tanstack/react-start'
|
|
24
25
|
import { z } from 'zod'
|
|
26
|
+
import { defineAction } from '@softr/sdk'
|
|
25
27
|
import { ds } from '@softr/sdk/server'
|
|
26
|
-
import { q } from '@softr/sdk'
|
|
27
28
|
```
|
|
28
29
|
|
|
29
|
-
Pass a zod schema straight to `.validator
|
|
30
|
+
A write's input arrives from the client, so validate it. Pass a zod schema straight to `.validator`; Start accepts it and types `data` from it.
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
## Declare the write
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
Every `ds` call takes an action, and an action declares one operation. A create action only works with `ds.create`; hand it to `ds.update` and the call throws `action "createProject" is declared as create, not update`.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
const createProject = defineAction({
|
|
38
|
+
id: 'createProject',
|
|
39
|
+
datasource: 'c9a51f22-3b8e-4d17-a6c0-5e92d84b7f31',
|
|
40
|
+
operation: 'create',
|
|
41
|
+
fields: {
|
|
42
|
+
name: 'fZk3Qm9pLw2Xa1', // SINGLE_LINE_TEXT
|
|
43
|
+
status: 'Ru7bVx2cNq0Ed9', // SELECT, choice ids 'draft' | 'active' | 'archived'
|
|
44
|
+
dueDate: 'Xp2dLh6vKz9Fa3', // DATETIME
|
|
45
|
+
},
|
|
46
|
+
permission: { userGroup: 'LOGGED_IN_USERS' },
|
|
47
|
+
})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The declaration follows the same rules as a read action, spelled out in [reading-records](../reading-records/SKILL.md): `datasource` is the opaque UUID from the table's registration, never derived from its name; the keys of `fields` are names you choose and the values are opaque field ids; everything sits at module scope with every property a literal.
|
|
51
|
+
|
|
52
|
+
The action's `fields` bound the write: `data` can only name them, and the returned record only carries them. A delete action declares no `fields`; giving it some is a type error.
|
|
34
53
|
|
|
35
54
|
## Core Patterns
|
|
36
55
|
|
|
37
56
|
### Create a record
|
|
38
57
|
|
|
39
58
|
```ts
|
|
40
|
-
const NewProject = z.object({ name: z.string(),
|
|
59
|
+
const NewProject = z.object({ name: z.string(), dueDate: z.string() })
|
|
41
60
|
|
|
42
|
-
const
|
|
61
|
+
const addProject = createServerFn({ method: 'POST' })
|
|
43
62
|
.validator(NewProject)
|
|
44
63
|
.handler(async ({ data }) =>
|
|
45
|
-
ds.create(
|
|
46
|
-
data,
|
|
47
|
-
select: q.select({ name: 'fZk3Qm9pLw2Xa1', status: 'Ru7bVx2cNq0Ed9' }),
|
|
64
|
+
ds.create(createProject, {
|
|
65
|
+
data: { name: data.name, dueDate: data.dueDate, status: { id: 'draft' } },
|
|
48
66
|
}),
|
|
49
67
|
)
|
|
50
68
|
```
|
|
51
69
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
**The payload key is `data`, not `fields`.** A record answers with `fields`, which is where the confusion comes from, but the option you pass is `data`. `CreateOptions` requires it, so a call keyed by `fields` does not type-check.
|
|
55
|
-
|
|
56
|
-
**`data` speaks whatever `select` maps.** The keys above are the schema's own names because `select` maps them to field ids, which is why the validated input can go straight through. With no `select`, `data` has to be keyed by raw field ids instead.
|
|
70
|
+
Returns the created record as `{ id, fields }`, with `fields` keyed by the action's names and holding the same value shapes a read returns. Every key in `data` is optional. A field left out, or written as `null`, gets the datasource's default.
|
|
57
71
|
|
|
58
72
|
### Update a record
|
|
59
73
|
|
|
60
74
|
```ts
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
75
|
+
const updateProject = defineAction({
|
|
76
|
+
id: 'updateProject',
|
|
77
|
+
datasource: 'c9a51f22-3b8e-4d17-a6c0-5e92d84b7f31',
|
|
78
|
+
operation: 'update',
|
|
79
|
+
fields: { status: 'Ru7bVx2cNq0Ed9', dueDate: 'Xp2dLh6vKz9Fa3' },
|
|
80
|
+
permission: { userGroup: 'LOGGED_IN_USERS' },
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const archiveProject = createServerFn({ method: 'POST' })
|
|
84
|
+
.validator(z.object({ id: z.string() }))
|
|
65
85
|
.handler(async ({ data }) =>
|
|
66
|
-
ds.update('
|
|
67
|
-
id: data.id,
|
|
68
|
-
data: { name: data.name },
|
|
69
|
-
select: q.select({ name: 'fZk3Qm9pLw2Xa1' }),
|
|
70
|
-
}),
|
|
86
|
+
ds.update(updateProject, { id: data.id, data: { status: { id: 'archived' } } }),
|
|
71
87
|
)
|
|
72
88
|
```
|
|
73
89
|
|
|
74
|
-
The record id is its own option
|
|
75
|
-
|
|
76
|
-
`update` writes the keys in `data` and leaves every other field alone, so a field you leave out is a field you are not writing. An empty `id` throws.
|
|
77
|
-
|
|
78
|
-
### What a write answers with
|
|
79
|
-
|
|
80
|
-
`create` and `update` resolve to `{ id, fields }`, but `fields` is **not the record**. On a Softr table it carries only the keys you sent, aliased through `select`; a status change answers with `{ id, fields: { status } }` and nothing else. (Other datasource types may answer with more. Rely on neither.)
|
|
90
|
+
The record id is its own option; keep it out of `data`. An empty `id` throws.
|
|
81
91
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
- **A quick change to one field** — a status select, a card dragged to another column, an archive toggle — is optimistic: change that one field in your own state immediately, keep the rest of the record as the loader gave it, save in the background, and on a rejected write restore the old value with a clear message. Do not `router.invalidate()` or re-fetch after a successful write; the local change is already what the user sees.
|
|
85
|
-
- **A saved form, or anything that changes several fields**, refreshes the route's data — `router.invalidate()`, see `@tanstack/router-core#router-core/data-loading` — so the list or detail re-reads through its own `select`.
|
|
86
|
-
|
|
87
|
-
Do not re-read the one record with `ds.get` for either: it takes no `select`, so it answers with raw field ids.
|
|
88
|
-
|
|
89
|
-
### Writing a choice field
|
|
90
|
-
|
|
91
|
-
A `SELECT` or `LINKED_RECORD` field reads back as `{ id, label }` (see [field-types](../reading-records/references/field-types.md)). Writing one accepts the option's `id`, its `label`, or `{ id, label }`, matched case-insensitively.
|
|
92
|
-
|
|
93
|
-
**A value that matches no option is not an error — it silently adds a new option to the field.** A typo'd label leaves a junk choice in the schema. So write `{ label }` (or the `{ id, label }` you read), copy labels from the table's schema verbatim, and never invent one.
|
|
92
|
+
`update` writes the keys in `data` and leaves every other field alone, so a field you leave out is a field you are not writing. On an update, `null` clears the field, where on a create it reached the default. Returns the updated record.
|
|
94
93
|
|
|
95
94
|
### Delete a record
|
|
96
95
|
|
|
97
96
|
```ts
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
97
|
+
const deleteProject = defineAction({
|
|
98
|
+
id: 'deleteProject',
|
|
99
|
+
datasource: 'c9a51f22-3b8e-4d17-a6c0-5e92d84b7f31',
|
|
100
|
+
operation: 'delete',
|
|
101
|
+
permission: { userGroup: 'LOGGED_IN_USERS' },
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const removeProject = createServerFn({ method: 'POST' })
|
|
105
|
+
.validator(z.object({ id: z.string() }))
|
|
106
|
+
.handler(async ({ data }) => {
|
|
107
|
+
await ds.delete(deleteProject, { id: data.id })
|
|
108
|
+
})
|
|
103
109
|
```
|
|
104
110
|
|
|
105
|
-
|
|
111
|
+
Resolves to nothing. A record that cannot be deleted throws.
|
|
106
112
|
|
|
107
|
-
###
|
|
113
|
+
### What a write accepts
|
|
108
114
|
|
|
109
|
-
`
|
|
115
|
+
With `softr-env.d.ts` in place (see [reading-records](../reading-records/SKILL.md#typing)), each key of `data` accepts exactly what its field takes:
|
|
110
116
|
|
|
111
|
-
|
|
117
|
+
- SELECT and LINKED_RECORD take `{ id }`. Not a bare string, and not the `{ id, label }` a read returns; the label is display data and a write does not accept it. A generated SELECT narrows the id to the field's own choices.
|
|
118
|
+
- CHECKBOX takes a boolean. Dates take ISO strings, never `Date` instances.
|
|
119
|
+
- ATTACHMENT takes `{ url }`, optionally with `filename`, `id` and `type`.
|
|
120
|
+
- A multi-entry field takes an array of the value.
|
|
112
121
|
|
|
113
122
|
### Writing on behalf of the visitor
|
|
114
123
|
|
|
@@ -116,6 +125,16 @@ Stamping a record with who created it, or scoping an update to the person making
|
|
|
116
125
|
|
|
117
126
|
## Failure Modes
|
|
118
127
|
|
|
128
|
+
**A write with the wrong action.** Each operation needs an action declared for it. `ds.update` with a read action throws `action "..." is declared as read, not update` at the first call, not at build time.
|
|
129
|
+
|
|
130
|
+
**A payload keyed `fields`.** The option is `data`. A record answers with `fields`, which is where the confusion comes from, but the option you pass is `data`, and the options type requires it.
|
|
131
|
+
|
|
132
|
+
**A SELECT or LINKED_RECORD written as a bare string.** The write shape is `{ id }`. Writing `status: 'archived'` where `status: { id: 'archived' }` belongs is a type error with codegen in place and a silent misfire without it.
|
|
133
|
+
|
|
134
|
+
**Feeding a read value back into a write.** A read returns `{ id, label }`; a write takes `{ id }` only. Strip the label before writing the value back.
|
|
135
|
+
|
|
136
|
+
**A field missing from the action.** `data` can only name the action's own `fields`. A field you need to write must be declared on the action, and a delete action declares none.
|
|
137
|
+
|
|
119
138
|
**A datasource id built from a name.** `'ds_projects'`, the table id, or the table's name glued to a uuid are not datasource ids. The id is the opaque UUID assigned when the table was registered on the application; copy it from there. Anything else fails at request time as not found or forbidden.
|
|
120
139
|
|
|
121
140
|
**A datasource id stored in a variable.** Pass the UUID as a string literal directly at the call site. The lint rule `@softr/ds-no-computed-id` catches this at development time.
|
|
@@ -134,5 +153,6 @@ Stamping a record with who created it, or scoping an update to the person making
|
|
|
134
153
|
|
|
135
154
|
## References
|
|
136
155
|
|
|
137
|
-
- [reading-records/SKILL.md](../reading-records/SKILL.md): fetching the record a write is about to change
|
|
156
|
+
- [reading-records/SKILL.md](../reading-records/SKILL.md): declaring actions, fetching the record a write is about to change, and the codegen the types come from
|
|
157
|
+
- [reading-records/references/field-types.md](../reading-records/references/field-types.md): the value shape every field reads back as
|
|
138
158
|
- [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor off `context` to stamp or scope a write
|