@softr/sdk 0.0.27 → 0.0.29

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.
@@ -1,5 +1,5 @@
1
1
  //#region src/core/integration.d.ts
2
- declare function integrationFetch(integrationId: string, input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
2
+ declare function integrationFetch(datasourceId: string, input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
3
3
  declare const integration: {
4
4
  fetch: typeof integrationFetch;
5
5
  };
@@ -2,7 +2,7 @@ import { readSessionToken } from "../auth/session.js";
2
2
  import { dsUrl } from "../datasource/record.js";
3
3
 
4
4
  //#region src/core/integration.ts
5
- async function integrationFetch(integrationId, input, init) {
5
+ async function integrationFetch(datasourceId, input, init) {
6
6
  const req = new Request(input, init);
7
7
  if (req.signal.aborted) throw req.signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
8
8
  const reqHeaders = {};
@@ -16,7 +16,7 @@ async function integrationFetch(integrationId, input, init) {
16
16
  };
17
17
  const method = req.method.toUpperCase();
18
18
  if (method !== "GET" && method !== "HEAD" && req.body !== null) payload.body = await req.clone().text();
19
- return fetch(`${dsUrl(integrationId)}/proxy`, {
19
+ return fetch(`${dsUrl(datasourceId)}/proxy`, {
20
20
  method: "POST",
21
21
  headers: {
22
22
  "Content-Type": "application/json",
@@ -232,7 +232,8 @@ async function deleteRecord(action, options) {
232
232
  if (!res.ok) throw new Error(`Softr datasource delete failed for "${datasourceId}": ${res.status}`);
233
233
  }
234
234
  function resolveTarget(subject, operation) {
235
- assert(isAction(subject), "a read or a mutation takes an action from defineAction");
235
+ const declaredBy = operation === "read" ? "defineQuery" : "defineMutation";
236
+ assert(isAction(subject), () => `Expected an action from ${declaredBy} but got ${JSON.stringify(subject)}`);
236
237
  const action = subject;
237
238
  assert(action.operation === operation, () => `action "${action.id}" is declared as ${action.operation}, not ${operation}`);
238
239
  return {
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { SoftrUserGroupId, SoftrUserGroups, User } from "./auth/user.js";
2
- import { SoftrDatasources, defineAction } from "./action.js";
2
+ import { SoftrDatasources, defineMutation, defineQuery } from "./action.js";
3
3
  import { SoftrContext } from "./auth/context.js";
4
4
  import { defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError } from "./routes/visibility.js";
5
5
  import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser, usePermissions } from "./auth/isomorphic.js";
6
6
  import { useUserTimezone } from "./timezone.js";
7
7
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
8
- export { DefaultErrorOverlay, type SoftrContext, type SoftrDatasources, type SoftrUserGroupId, type SoftrUserGroups, type User, defineAction, defineVisibility, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, usePermissions, useUserTimezone };
8
+ export { DefaultErrorOverlay, type SoftrContext, type SoftrDatasources, type SoftrUserGroupId, type SoftrUserGroups, type User, defineMutation, defineQuery, defineVisibility, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, usePermissions, useUserTimezone };
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { defineAction } from "./action.js";
1
+ import { defineMutation, defineQuery } from "./action.js";
2
2
  import { defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError } from "./routes/visibility.js";
3
3
  import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser, usePermissions } from "./auth/isomorphic.js";
4
4
  import { useUserTimezone } from "./timezone.js";
5
5
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
6
6
 
7
- export { DefaultErrorOverlay, defineAction, defineVisibility, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, usePermissions, useUserTimezone };
7
+ export { DefaultErrorOverlay, defineMutation, defineQuery, defineVisibility, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, usePermissions, useUserTimezone };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.0.27";
2
+ var version = "0.0.29";
3
3
 
4
4
  //#endregion
5
5
  export { version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
@@ -5,13 +5,13 @@ 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, writing records, or auth. Load
9
- this when unsure which sub-skill applies, or when touching the wiring; each
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
14
- library_version: 0.0.27
14
+ library_version: 0.0.29
15
15
  ---
16
16
 
17
17
  # Softr SDK core
@@ -23,13 +23,13 @@ metadata:
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
24
  > **CRITICAL**: `workflow.trigger` is exploratory. It exists on the entrypoints below and typechecks, but its endpoint is not final. Do not build a feature on it. Tell the user the capability is not ready and ask how they want to proceed.
25
25
 
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`.
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 `record-queries` plus `routes-and-visibility`.
27
27
 
28
28
  ## Entrypoints
29
29
 
30
30
  | Import | Runs | Holds |
31
31
  | --- | --- | --- |
32
- | `@softr/sdk` | Both | `defineVisibility`, `q`, `useCurrentUser`, `usePermissions`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
32
+ | `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `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` |
@@ -40,8 +40,9 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
40
40
  | --- | --- |
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
- | Fetch records from a datasource | [reading-records/SKILL.md](./reading-records/SKILL.md) |
44
- | Create, update or delete a record | [data-mutations/SKILL.md](./data-mutations/SKILL.md) |
43
+ | Fetch records from a datasource | [record-queries/SKILL.md](./record-queries/SKILL.md) |
44
+ | Chart, aggregate or count records | [charts-and-metrics/SKILL.md](./charts-and-metrics/SKILL.md) |
45
+ | Create, update or delete a record | [record-mutations/SKILL.md](./record-mutations/SKILL.md) |
45
46
  | Sign a visitor in, out, or up | [auth/SKILL.md](./auth/SKILL.md) |
46
47
  | Call a connected third-party API (Slack, Stripe, Gmail, …) | [integrations/SKILL.md](./integrations/SKILL.md) |
47
48
 
@@ -55,10 +56,13 @@ Showing the visitor's name, or hiding UI they cannot use?
55
56
  load softr-sdk/current-user
56
57
 
57
58
  Putting records on a page, in a table, a list or a detail view?
58
- load softr-sdk/reading-records
59
+ load softr-sdk/record-queries
60
+
61
+ Drawing a chart, a stat tile, or any aggregated number?
62
+ load softr-sdk/charts-and-metrics
59
63
 
60
64
  Submitting a form, editing a record, or deleting one?
61
- load softr-sdk/data-mutations
65
+ load softr-sdk/record-mutations
62
66
 
63
67
  Wiring a login, signup, or sign-out?
64
68
  load softr-sdk/auth
@@ -125,7 +129,3 @@ type User = {
125
129
  userGroupIds: Array<SoftrUserGroupId> // plain string unless you declare your ids
126
130
  }
127
131
  ```
128
-
129
- ## Version
130
-
131
- Targets `@softr/sdk` v0.0.26.
@@ -16,7 +16,7 @@ description: >-
16
16
  metadata:
17
17
  type: sub-skill
18
18
  library: softr-sdk
19
- library_version: 0.0.27
19
+ library_version: 0.0.29
20
20
  ---
21
21
 
22
22
  **Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineVisibility`, the hooks and every type come from `@softr/sdk`. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
@@ -0,0 +1,180 @@
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 controls.
8
+ Use when drawing a chart, a stat tile or any counted, summed or averaged
9
+ number, when filling a dropdown from a real column, or when a chart comes back
10
+ scrambled or with the wrong rows. Aggregation only: fetching individual
11
+ records is record-queries, and changing them is record-mutations.
12
+ metadata:
13
+ type: sub-skill
14
+ library: softr-sdk
15
+ library_version: 0.0.29
16
+ ---
17
+
18
+ **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
19
+
20
+ # Charts and metrics
21
+
22
+ `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 query `ds.list` takes, with the same `where` filters.
23
+
24
+ ## Setup
25
+
26
+ ```ts
27
+ import { createServerFn } from '@tanstack/react-start'
28
+ import { defineQuery } from '@softr/sdk'
29
+ import { ds } from '@softr/sdk/server'
30
+
31
+ const sales = defineQuery({
32
+ id: 'sales',
33
+ datasource: '00000000-0000-0000-0000-000000000000',
34
+ fields: {
35
+ amount: 'Vk9cXm2pSz6Ld4', // CURRENCY
36
+ region: 'fZk3Qm9pLw2Xa1', // SELECT
37
+ status: 'Ru7bVx2cNq0Ed9', // SELECT, choice ids 'paid' | 'open' | 'void'
38
+ closedAt: 'Xp2dLh6vKz9Fa3', // DATETIME
39
+ rep: 'Gq5wNt8rJy3Hb1', // LINKED_RECORD
40
+ },
41
+ permission: { userGroup: 'LOGGED_IN_USERS' },
42
+ })
43
+ ```
44
+
45
+ A chart only sees the query's declared fields, so a field to group, aggregate or filter by must be in `fields`. Declaring a query belongs to [record-queries](../record-queries/SKILL.md); one query serves `ds.list`, `ds.chart` and `ds.metric`.
46
+
47
+ ## Core Patterns
48
+
49
+ ### A bar chart
50
+
51
+ ```ts
52
+ const ordersByRegion = createServerFn().handler(async () => {
53
+ const { data } = await ds
54
+ .chart(sales)
55
+ .groupBy((q) => ({ region: q.region }))
56
+ .metrics((q) => ({ orders: q.$count }))
57
+ .orderBy((q) => q.orders.desc)
58
+ return data
59
+ })
60
+ // data: Array<{ region: string | null; orders: number }>
61
+ ```
62
+
63
+ 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.
64
+
65
+ 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.
66
+
67
+ ### A time series
68
+
69
+ ```ts
70
+ const { data } = await ds
71
+ .chart(sales)
72
+ .groupBy((q) => ({ month: q.closedAt.byMonth }))
73
+ .metrics((q) => ({ revenue: q.amount.sum }))
74
+ .where((q) => q.status.is('paid'))
75
+ .orderBy((q) => q.month.asc)
76
+ // data: Array<{ month: string | null; revenue: number | null }>
77
+ ```
78
+
79
+ `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"`.
80
+
81
+ **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.
82
+
83
+ ### Spread, not just totals
84
+
85
+ ```ts
86
+ const { data } = await ds
87
+ .chart(sales)
88
+ .groupBy((q) => ({ region: q.region }))
89
+ .metrics((q) => ({
90
+ smallest: q.amount.min,
91
+ typical: q.amount.avg,
92
+ biggest: q.amount.max,
93
+ }))
94
+ .orderBy((q) => q.typical.desc)
95
+ ```
96
+
97
+ 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.
98
+
99
+ ### Two group columns, which is the ceiling
100
+
101
+ ```ts
102
+ const { data } = await ds
103
+ .chart(sales)
104
+ .groupBy((q) => ({ month: q.closedAt.byMonth, region: q.region }))
105
+ .metrics((q) => ({ revenue: q.amount.sum }))
106
+ .orderBy((q) => [q.month.asc, q.revenue.desc])
107
+ // data: Array<{ month: string | null; region: string | null; revenue: number | null }>
108
+ ```
109
+
110
+ 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.
111
+
112
+ ### Distinct values, for a filter control
113
+
114
+ ```ts
115
+ const { data } = await ds.chart(sales).groupBy((q) => ({ region: q.region }))
116
+ // data: Array<{ region: string | null }>
117
+ ```
118
+
119
+ 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.
120
+
121
+ ### A headline number
122
+
123
+ ```ts
124
+ const { value } = await ds
125
+ .metric(sales, (q) => q.amount.sum)
126
+ .where((q) => q.status.is('paid'))
127
+ // value: number | null
128
+ ```
129
+
130
+ 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:
131
+
132
+ ```ts
133
+ const { value: open } = await ds.metric(sales, (q) => q.$count).where((q) => q.status.is('open'))
134
+ // open: number
135
+ ```
136
+
137
+ ### Top N
138
+
139
+ ```ts
140
+ const { data } = await ds
141
+ .chart(sales)
142
+ .groupBy((q) => ({ rep: q.rep }))
143
+ .metrics((q) => ({ revenue: q.amount.sum }))
144
+ .orderBy((q) => q.revenue.desc)
145
+
146
+ const top5 = data.slice(0, 5)
147
+ ```
148
+
149
+ A chart has no row limit to set, so a top-N is a client-side slice of the fully sorted rows.
150
+
151
+ ### A dashboard load
152
+
153
+ ```ts
154
+ const loadDashboard = createServerFn().handler(async () => {
155
+ const [mtd, series] = await Promise.all([
156
+ ds
157
+ .metric(sales, (q) => q.amount.sum)
158
+ .where((q) => q.$and(q.status.is('paid'), q.closedAt.gte('2026-09-01'))),
159
+ ds
160
+ .chart(sales)
161
+ .groupBy((q) => ({ month: q.closedAt.byMonth }))
162
+ .metrics((q) => ({ revenue: q.amount.sum }))
163
+ .where((q) => q.status.is('paid'))
164
+ .orderBy((q) => q.month.asc),
165
+ ])
166
+ return { mtd: mtd.value, series: series.data }
167
+ })
168
+ ```
169
+
170
+ Each tile and panel is its own request, fired in parallel from one handler.
171
+
172
+ ## Order of calls
173
+
174
+ `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.
175
+
176
+ ## References
177
+
178
+ - [references/aggregations.md](./references/aggregations.md): every bucket interval and its value type, and which aggregations each kind of field offers. Load it when choosing a bucket or when a field does not offer the aggregation you expected.
179
+ - [record-queries/SKILL.md](../record-queries/SKILL.md): declaring the query a chart aggregates over. Load it when the declaration itself is what you are writing.
180
+ - [record-queries/references/filters-and-sorting.md](../record-queries/references/filters-and-sorting.md): every filter operator by field type. Load it when the `where` you need is not shown here, or when a filter is rejected.
@@ -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 |
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 |
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.27
13
+ library_version: 0.0.29
14
14
  ---
15
15
 
16
16
  **Boundary.** `useCurrentUser`, `usePermissions` and every type come from `@softr/sdk` and work anywhere; `ds` and `auth` live on `@softr/sdk/server` and run only inside a `createServerFn` handler. The app is already wired — `src/start.ts` registers `softrAuthMiddleware`, `src/routes/__root.tsx` publishes the visitor as route context — never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
@@ -69,7 +69,7 @@ A `guard()` in `beforeLoad` guarantees a visitor at runtime, but it does not cha
69
69
  import { createServerFn } from '@tanstack/react-start'
70
70
 
71
71
  // The datasource id from the table's registration on the application — not the table id.
72
- const INVOICES = 'e4b7c2a1-9d3f-4e68-b05a-7f1c6d2e8a93'
72
+ const INVOICES = '00000000-0000-0000-0000-000000000001'
73
73
 
74
74
  const getMyInvoices = createServerFn().handler(async ({ context }) => {
75
75
  if (!context.user) throw new Error('Not signed in')
@@ -88,7 +88,7 @@ const getMyInvoices = createServerFn().handler(async ({ context }) => {
88
88
  ```tsx
89
89
  import { usePermissions } from '@softr/sdk'
90
90
 
91
- const INVOICES = 'e4b7c2a1-9d3f-4e68-b05a-7f1c6d2e8a93' // the registered datasource's id
91
+ const INVOICES = '00000000-0000-0000-0000-000000000001' // the registered datasource's id
92
92
 
93
93
  function NewInvoiceButton() {
94
94
  const { create } = usePermissions(INVOICES)
@@ -107,8 +107,8 @@ Pass an id the visitor's session has no entry for and it throws while rendering,
107
107
  declare module '@softr/sdk' {
108
108
  interface SoftrUserGroups {
109
109
  ids:
110
- | 'b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d'
111
- | 'f9e8d7c6-5b4a-4938-8271-605f4e3d2c1b'
110
+ | '00000000-0000-0000-0000-00000000000a'
111
+ | '00000000-0000-0000-0000-00000000000b'
112
112
  }
113
113
  }
114
114
  export {}
@@ -119,5 +119,5 @@ With this declaration, `user.userGroupIds` and every `userGroupIds` the SDK acce
119
119
  ## References
120
120
 
121
121
  - [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): `isAuthorized()` narrows `context.user` while applying the route's rule
122
- - [reading-records/SKILL.md](../reading-records/SKILL.md): filtering a query by the visitor, and who really authorizes the read
122
+ - [record-queries/SKILL.md](../record-queries/SKILL.md): filtering a query by the visitor, and who really authorizes the read
123
123
  - [auth/SKILL.md](../auth/SKILL.md): what changes the value `useCurrentUser` returns
@@ -1,15 +1,16 @@
1
1
  ---
2
2
  name: integrations
3
3
  description: >-
4
- Call a third-party service connected to the app as an integration: integration.fetch
5
- from @softr/sdk/server, reading the datasource id and integration type from the
6
- "Attached data sources" prompt section, looking up the base URL from the reference
7
- table by integrationType, and constructing the full API URL from base URL plus the
8
- path and query params the model knows from training. Check res.ok before res.json().
4
+ Call a third-party service connected to the app as an integration:
5
+ integration.fetch from @softr/sdk/server, reading the datasource id and
6
+ integration type from the "Attached data sources" prompt section, looking up
7
+ the base URL from the reference table by integrationType, and constructing the
8
+ full API URL from base URL plus the path and query params the model knows from
9
+ training. Check res.ok before res.json().
9
10
  metadata:
10
11
  type: sub-skill
11
12
  library: softr-sdk
12
- library_version: 0.0.27
13
+ library_version: 0.0.29
13
14
  ---
14
15
 
15
16
  **Boundary.** `integration` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
@@ -33,7 +34,7 @@ When the user attaches integrations to the chat, they appear in the `## Attached
33
34
  - "Gmail" — database `<databaseId>`, integration `<integrationId>`, type `GMAIL`
34
35
  ```
35
36
 
36
- Use `databaseId` as the first argument to `integration.fetch`. Use `integrationType` (e.g. `GMAIL`) to look up the base URL from the reference table below — then construct the full URL by appending the path and query parameters your training knows for that API.
37
+ Use `databaseId` as the first argument to `integration.fetch` — that is the `database` coordinate, not the `integration` one. The `integration` value is the OAuth connection identifier; do not pass it to `integration.fetch`. Use `integrationType` (e.g. `GMAIL`) to look up the base URL from the reference table below — then construct the full URL by appending the path and query parameters your training knows for that API.
37
38
 
38
39
  ## Core pattern
39
40
 
@@ -49,7 +50,7 @@ const getCalendarEvents = createServerFn().handler(async () => {
49
50
  })
50
51
  ```
51
52
 
52
- The id (`25d60213-…`) is the `databaseId` from the attached data sources section. The base URL (`https://www.googleapis.com`) is looked up from the reference table below by matching `integrationType` (`GOOGLE_CALENDAR`). The path and query params come from your knowledge of the Google Calendar REST API.
53
+ The id (`25d60213-…`) is the `databaseId` from the attached data sources section — the `database` coordinate, not the `integration` one. The base URL (`https://www.googleapis.com`) is looked up from the reference table below by matching `integrationType` (`GOOGLE_CALENDAR`). The path and query params come from your knowledge of the Google Calendar REST API.
53
54
 
54
55
  ## POST request
55
56
 
@@ -0,0 +1,149 @@
1
+ ---
2
+ name: record-mutations
3
+ description: >-
4
+ Create, update and delete records in a Softr datasource with defineMutation,
5
+ ds.create, ds.update and ds.delete: one declaration per operation, the data
6
+ payload keyed by your own field names, what each field type accepts on a
7
+ write, and the record each call returns. Use when submitting a form, saving an
8
+ edit or deleting a record, or when a write throws, writes the wrong field or
9
+ silently changes nothing. Writes only: fetching records is record-queries, and
10
+ aggregating them is charts-and-metrics.
11
+ metadata:
12
+ type: sub-skill
13
+ library: softr-sdk
14
+ library_version: 0.0.29
15
+ ---
16
+
17
+ **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineMutation` and every type come from `@softr/sdk` and can be imported anywhere. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
18
+
19
+ # Writing records
20
+
21
+ `ds` writes records to a Softr datasource, from the server only.
22
+
23
+ ## Setup
24
+
25
+ ```ts
26
+ import { createServerFn } from '@tanstack/react-start'
27
+ import { z } from 'zod'
28
+ import { defineMutation } from '@softr/sdk'
29
+ import { ds } from '@softr/sdk/server'
30
+ ```
31
+
32
+ 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.
33
+
34
+ ## Declare the write
35
+
36
+ A mutation declares exactly one operation, and each of the three needs its own declaration. One datasource carries as many mutations as the app needs, alongside its queries. A create mutation only works with `ds.create`; hand it to `ds.update` and the call throws `action "createProject" is declared as create, not update`.
37
+
38
+ ```ts
39
+ const createProject = defineMutation({
40
+ id: 'createProject',
41
+ datasource: '00000000-0000-0000-0000-000000000000',
42
+ operation: 'create',
43
+ fields: {
44
+ name: 'fZk3Qm9pLw2Xa1', // SINGLE_LINE_TEXT
45
+ status: 'Ru7bVx2cNq0Ed9', // SELECT, choice ids 'draft' | 'active' | 'archived'
46
+ dueDate: 'Xp2dLh6vKz9Fa3', // DATETIME
47
+ },
48
+ permission: { userGroup: 'LOGGED_IN_USERS' },
49
+ })
50
+ ```
51
+
52
+ The declaration follows the same rules as a read, spelled out in [record-queries](../record-queries/SKILL.md): `id` reads like a name and is unique across the app, queries counted alongside mutations; `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; `permission` declares who may run the mutation, from the same three choices, and the SDK enforces it; and the object is written inline, because passing it through a variable or a spread widens the field ids and the types stop checking.
53
+
54
+ The mutation's `fields` bound the write: `data` can only name them, and the returned record only carries them. A delete mutation declares no `fields`, and giving it some is a type error.
55
+
56
+ ## Core Patterns
57
+
58
+ ### Create a record
59
+
60
+ ```ts
61
+ const NewProject = z.object({ name: z.string(), dueDate: z.string() })
62
+
63
+ const addProject = createServerFn({ method: 'POST' })
64
+ .validator(NewProject)
65
+ .handler(async ({ data }) =>
66
+ ds.create(createProject, {
67
+ data: { name: data.name, dueDate: data.dueDate, status: { id: 'draft' } },
68
+ }),
69
+ )
70
+ ```
71
+
72
+ Returns the created record as `{ id, fields }`, with `fields` keyed by the mutation'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.
73
+
74
+ ### Update a record
75
+
76
+ ```ts
77
+ const updateProject = defineMutation({
78
+ id: 'updateProject',
79
+ datasource: '00000000-0000-0000-0000-000000000000',
80
+ operation: 'update',
81
+ fields: { status: 'Ru7bVx2cNq0Ed9', dueDate: 'Xp2dLh6vKz9Fa3' },
82
+ permission: { userGroup: 'LOGGED_IN_USERS' },
83
+ })
84
+
85
+ const archiveProject = createServerFn({ method: 'POST' })
86
+ .validator(z.object({ id: z.string() }))
87
+ .handler(async ({ data }) =>
88
+ ds.update(updateProject, { id: data.id, data: { status: { id: 'archived' } } }),
89
+ )
90
+ ```
91
+
92
+ The record id is its own option; keep it out of `data`. An empty `id` throws.
93
+
94
+ `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.
95
+
96
+ ### Delete a record
97
+
98
+ ```ts
99
+ const deleteProject = defineMutation({
100
+ id: 'deleteProject',
101
+ datasource: '00000000-0000-0000-0000-000000000000',
102
+ operation: 'delete',
103
+ permission: { userGroup: 'LOGGED_IN_USERS' },
104
+ })
105
+
106
+ const removeProject = createServerFn({ method: 'POST' })
107
+ .validator(z.object({ id: z.string() }))
108
+ .handler(async ({ data }) => {
109
+ await ds.delete(deleteProject, { id: data.id })
110
+ })
111
+ ```
112
+
113
+ Resolves to nothing. A record that cannot be deleted throws.
114
+
115
+ ### Edit an existing record
116
+
117
+ An edit form needs both halves: a `defineQuery` to load the record and a `defineMutation` to save it. They are separate declarations over the same table, so give both the fields the form touches, and read the loaded values off the query's names.
118
+
119
+ ```ts
120
+ const projectDetail = defineQuery({
121
+ id: 'projectDetail',
122
+ datasource: '00000000-0000-0000-0000-000000000000',
123
+ fields: { status: 'Ru7bVx2cNq0Ed9', dueDate: 'Xp2dLh6vKz9Fa3' },
124
+ permission: { userGroup: 'LOGGED_IN_USERS' },
125
+ })
126
+
127
+ const project = await ds.get(projectDetail, { id })
128
+ await ds.update(updateProject, {
129
+ id,
130
+ data: { status: { id: project.fields.status.id } },
131
+ })
132
+ ```
133
+
134
+ A read answers a SELECT as `{ id, label }` and a write takes `{ id }`, so pass the id through rather than the whole value.
135
+
136
+ ### What a write accepts
137
+
138
+ Each key of `data` accepts exactly what its field takes (see [record-queries](../record-queries/SKILL.md#typing)):
139
+
140
+ - 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.
141
+ - CHECKBOX takes a boolean. Dates take ISO strings, never `Date` instances.
142
+ - ATTACHMENT takes `{ url }`, optionally with `filename`, `id` and `type`.
143
+ - A multi-entry field takes an array of the value.
144
+
145
+ ## References
146
+
147
+ - [record-queries/SKILL.md](../record-queries/SKILL.md): declaring the read half of an edit form, and the codegen the write types come from. Load it alongside this skill for any screen that shows a record before changing it.
148
+ - [record-queries/references/field-types.md](../record-queries/references/field-types.md): the value shape every field reads back as. Load it when you are unsure what a field accepts.
149
+ - [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor off `context`