@softr/sdk 0.0.24 → 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/assertions.js +1 -7
- package/dist/auth/isomorphic.d.ts +1 -2
- package/dist/auth/methods/authenticator.d.ts +80 -0
- package/dist/auth/methods/authenticator.js +175 -0
- package/dist/auth/methods/code.d.ts +40 -0
- package/dist/auth/methods/code.js +74 -0
- package/dist/auth/methods/magicLink.d.ts +24 -0
- package/dist/auth/methods/magicLink.js +53 -0
- package/dist/auth/methods/password.d.ts +19 -5
- package/dist/auth/methods/password.js +36 -15
- package/dist/auth/methods/signup.d.ts +22 -14
- package/dist/auth/methods/signup.js +48 -54
- package/dist/auth/methods/social.d.ts +35 -0
- package/dist/auth/methods/social.js +58 -0
- package/dist/auth/methods/utils.d.ts +1 -1
- package/dist/auth/permissions.d.ts +7 -2
- package/dist/auth/platform.js +185 -7
- package/dist/auth/server.d.ts +15 -0
- package/dist/auth/server.js +16 -1
- package/dist/auth/session.js +35 -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 +11 -7
- package/skills/softr-sdk/auth/SKILL.md +222 -36
- 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/current-user/SKILL.md +1 -1
- package/skills/softr-sdk/data-mutations/SKILL.md +76 -56
- package/skills/softr-sdk/reading-records/SKILL.md +77 -93
- 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/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,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 |
|
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
13
|
+
library_version: 0.0.25
|
|
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.
|
|
@@ -2,13 +2,14 @@
|
|
|
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
|
|
11
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.25
|
|
12
13
|
---
|
|
13
14
|
|
|
14
15
|
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `q` 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.
|
|
@@ -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
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: reading-records
|
|
3
3
|
description: >-
|
|
4
|
-
Fetch records from a Softr datasource inside a server function:
|
|
5
|
-
ds.get from @softr/sdk/server, the
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
and chart data are not covered.
|
|
4
|
+
Fetch records from a Softr datasource inside a server function: declaring a
|
|
5
|
+
read with defineAction, ds.list and ds.get from @softr/sdk/server, the where
|
|
6
|
+
and orderBy callbacks, field aliases, the 20-record default and 100-record
|
|
7
|
+
cap, and nextOffset pagination. Reads only. Mutations, workflows,
|
|
8
|
+
integrations, charts and metrics are not covered.
|
|
10
9
|
metadata:
|
|
11
10
|
type: sub-skill
|
|
12
11
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.25
|
|
14
13
|
---
|
|
15
14
|
|
|
16
15
|
**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. `q` 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.
|
|
@@ -23,53 +22,37 @@ metadata:
|
|
|
23
22
|
|
|
24
23
|
```ts
|
|
25
24
|
import { createServerFn } from '@tanstack/react-start'
|
|
25
|
+
import { defineAction } from '@softr/sdk'
|
|
26
26
|
import { ds } from '@softr/sdk/server'
|
|
27
|
-
import { q } from '@softr/sdk'
|
|
28
27
|
```
|
|
29
28
|
|
|
30
|
-
|
|
29
|
+
`ds` is server-only. `defineAction` only declares data operations, so it ships on the root entrypoint and the declaration can sit anywhere.
|
|
31
30
|
|
|
32
|
-
|
|
31
|
+
## Declare the read
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
// correct
|
|
36
|
-
ds.list('8ffb4814-b028-4496-b679-7face2167ebc', { ... })
|
|
37
|
-
|
|
38
|
-
// wrong
|
|
39
|
-
const POSTS = '8ffb4814-b028-4496-b679-7face2167ebc'
|
|
40
|
-
ds.list(POSTS, { ... })
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
A record comes back as `{ id: string; fields: Record<string, unknown> }`. When you write a TypeScript type for the fields, the shape of each value depends on the Softr field type — see [references/field-types.md](./references/field-types.md) for the full mapping.
|
|
44
|
-
|
|
45
|
-
## Typing record fields
|
|
46
|
-
|
|
47
|
-
Declare a local type for the records you fetch using `SoftrRecord<Fields>`. Use the shapes from the field-types reference, not guesses from the field name. The most common mistake is treating SELECT and LINKED_RECORD fields as strings — they are objects:
|
|
33
|
+
Every `ds` call takes an action: which datasource, which fields, who may run it.
|
|
48
34
|
|
|
49
35
|
```ts
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
36
|
+
const publishedPosts = defineAction({
|
|
37
|
+
id: 'publishedPosts',
|
|
38
|
+
datasource: '8ffb4814-b028-4496-b679-7face2167ebc',
|
|
39
|
+
operation: 'read',
|
|
40
|
+
fields: {
|
|
41
|
+
title: 'fZk3Qm9pLw2Xa1', // SINGLE_LINE_TEXT
|
|
42
|
+
published: 'Ru7bVx2cNq0Ed9', // CHECKBOX
|
|
43
|
+
publishedAt: 'Xp2dLh6vKz9Fa3',// DATETIME
|
|
44
|
+
views: 'mB4kQ8yTn1sWc7', // NUMBER
|
|
45
|
+
},
|
|
46
|
+
permission: { userGroup: 'ALL_USERS' },
|
|
47
|
+
})
|
|
58
48
|
```
|
|
59
49
|
|
|
60
|
-
`
|
|
61
|
-
|
|
62
|
-
|
|
50
|
+
- `datasource` is the opaque UUID from the table's registration on the application (Softr Studio's Data tab, or the `register_application_data_source` tool when an agent does it). It is not the table id and is never derived from the table's name. `'ds_posts'` or a name glued to a uuid fails at request time as not found or forbidden.
|
|
51
|
+
- The keys of `fields` are names you choose. The values are field ids, opaque strings copied from `softr-env.d.ts` (see [Typing](#typing)) or the table's schema. Everything downstream speaks your names: the returned records, `where`, `orderBy`.
|
|
52
|
+
- `permission` declares who may run the action.
|
|
53
|
+
- Declare at module scope, every property a literal. The build reads the declaration without executing it, so a computed id, a shared constant or a spread does not work.
|
|
63
54
|
|
|
64
|
-
|
|
65
|
-
import type { ListResult, SoftrRecord } from '@softr/sdk'
|
|
66
|
-
|
|
67
|
-
const fetchProducts = createServerFn().handler(() =>
|
|
68
|
-
ds.list('3c1e8a52-6f0d-4b7e-9a21-5d84c0f7b2e6', { select, limit: 100 }) as Promise<ListResult<Product>>
|
|
69
|
-
)
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
`loaderData` is then already typed as `ListResult<Product>` — no second cast needed in the component. All the shapes in [references/field-types.md](./references/field-types.md) satisfy the `JsonValue` constraint.
|
|
55
|
+
An action fixes the table and the columns, not the rows: filters, sorting and paging stay at the call site, because a list screen changes them as the user types.
|
|
73
56
|
|
|
74
57
|
## Core Patterns
|
|
75
58
|
|
|
@@ -77,73 +60,62 @@ const fetchProducts = createServerFn().handler(() =>
|
|
|
77
60
|
|
|
78
61
|
```ts
|
|
79
62
|
const listPublishedPosts = createServerFn().handler(async () => {
|
|
80
|
-
return await ds
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
publishedAt: 'Xp2dLh6vKz9Fa3',
|
|
86
|
-
}),
|
|
87
|
-
where: q.boolean('published').is(true),
|
|
88
|
-
orderBy: q.desc('publishedAt'),
|
|
89
|
-
limit: 50,
|
|
90
|
-
})
|
|
63
|
+
return await ds
|
|
64
|
+
.list(publishedPosts)
|
|
65
|
+
.where((q) => q.published.is(true))
|
|
66
|
+
.orderBy((q) => q.publishedAt.desc)
|
|
67
|
+
.limit(50)
|
|
91
68
|
})
|
|
92
69
|
```
|
|
93
70
|
|
|
94
|
-
Returns `{ records, total, nextOffset }`. `
|
|
95
|
-
|
|
96
|
-
**Filtering or sorting requires a `select`, and it has to cover every field you name.** `where` and `orderBy` speak aliases, so without a map to translate through they throw at request time. That is why `published` is in the map above even though the table never displays it.
|
|
71
|
+
Returns `{ records, total, nextOffset }`. Each record is `{ id, fields }`, with `fields` keyed by the action's own names: every declared field is present, `null` when empty, and a field the action does not declare is not returned. `ds.list(publishedPosts)` with no calls chained returns the first 20 records. `limit` is clamped to 100; a larger number is silently reduced, so more than a hundred records means paginating.
|
|
97
72
|
|
|
98
|
-
###
|
|
73
|
+
### Filter with the `where` callback
|
|
99
74
|
|
|
100
|
-
|
|
75
|
+
The callback is handed one handle per declared field, carrying only the operators that field's type supports, plus `$and` and `$or`:
|
|
101
76
|
|
|
102
77
|
```ts
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
// records[0].fields is { title: ..., views: ... }
|
|
78
|
+
await ds.list(publishedPosts).where((q) =>
|
|
79
|
+
q.$and(
|
|
80
|
+
q.published.is(true),
|
|
81
|
+
q.views.gte(100),
|
|
82
|
+
q.$or(q.title.contains('cafe'), q.title.startsWith('The')),
|
|
83
|
+
),
|
|
84
|
+
)
|
|
111
85
|
```
|
|
112
86
|
|
|
113
|
-
|
|
87
|
+
Calling `.where` more than once ands the filters together, which is how a fixed scope and a user-supplied filter combine. Only the action's own fields can be filtered or sorted on; a field you need in a `where` must be in the action's `fields`, even when the page never displays it. The full operator matrix is in [references/query-builder.md](./references/query-builder.md).
|
|
114
88
|
|
|
115
|
-
###
|
|
89
|
+
### Sort
|
|
116
90
|
|
|
117
91
|
```ts
|
|
118
|
-
|
|
119
|
-
|
|
92
|
+
.orderBy((q) => q.publishedAt.desc)
|
|
93
|
+
.orderBy((q) => [q.views.desc, q.title.asc]) // array for tie-breakers
|
|
120
94
|
```
|
|
121
95
|
|
|
122
|
-
|
|
96
|
+
### Get one record
|
|
123
97
|
|
|
124
|
-
|
|
98
|
+
```ts
|
|
99
|
+
const post = await ds.get(publishedPosts, { id: postId })
|
|
100
|
+
if (post === null) throw notFound()
|
|
101
|
+
```
|
|
125
102
|
|
|
126
|
-
|
|
103
|
+
A record that does not exist comes back as `null`, not an error. A chained `.where` narrows which record the id may resolve to. A record that does not match reads as `null`, the same as one that does not exist:
|
|
127
104
|
|
|
128
105
|
```ts
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
q.or(
|
|
133
|
-
q.text('owner').is(user.id),
|
|
134
|
-
q.array('collaborators').hasAllOf([user.id]),
|
|
135
|
-
),
|
|
136
|
-
)
|
|
106
|
+
const post = await ds
|
|
107
|
+
.get(publishedPosts, { id: postId })
|
|
108
|
+
.where((q) => q.published.is(true))
|
|
137
109
|
```
|
|
138
110
|
|
|
139
|
-
|
|
111
|
+
There is no `orderBy`, `limit` or `offset` on a get. To find a record by a field instead of its id, use `ds.list` with a `limit` of 1.
|
|
140
112
|
|
|
141
113
|
### Paginate
|
|
142
114
|
|
|
143
115
|
```ts
|
|
144
|
-
const first = await ds.list(
|
|
116
|
+
const first = await ds.list(publishedPosts).limit(50)
|
|
145
117
|
if (first.nextOffset !== null) {
|
|
146
|
-
const second = await ds.list(
|
|
118
|
+
const second = await ds.list(publishedPosts).limit(50).offset(first.nextOffset)
|
|
147
119
|
}
|
|
148
120
|
```
|
|
149
121
|
|
|
@@ -152,13 +124,18 @@ if (first.nextOffset !== null) {
|
|
|
152
124
|
### Scope the read to the visitor
|
|
153
125
|
|
|
154
126
|
```ts
|
|
127
|
+
const myInvoices = defineAction({
|
|
128
|
+
id: 'myInvoices',
|
|
129
|
+
datasource: 'e4b7c2a1-9d3f-4e68-b05a-7f1c6d2e8a93',
|
|
130
|
+
operation: 'read',
|
|
131
|
+
fields: { owner: 'Gq5wNt8rJy3Hb1', amount: 'Vk9cXm2pSz6Ld4' },
|
|
132
|
+
permission: { userGroup: 'LOGGED_IN_USERS' },
|
|
133
|
+
})
|
|
134
|
+
|
|
155
135
|
const listMyInvoices = createServerFn().handler(async ({ context }) => {
|
|
156
136
|
if (!context.user) throw new Error('Not signed in')
|
|
157
|
-
|
|
158
|
-
return await ds.list(
|
|
159
|
-
select: q.select({ owner: 'Gq5wNt8rJy3Hb1', amount: 'Vk9cXm2pSz6Ld4' }),
|
|
160
|
-
where: q.text('owner').is(context.user.id),
|
|
161
|
-
})
|
|
137
|
+
const userId = context.user.id
|
|
138
|
+
return await ds.list(myInvoices).where((q) => q.owner.is(userId))
|
|
162
139
|
})
|
|
163
140
|
```
|
|
164
141
|
|
|
@@ -168,9 +145,16 @@ A server function is its own endpoint, reachable whether or not the visitor ever
|
|
|
168
145
|
|
|
169
146
|
A read the visitor is not allowed to make throws rather than coming back empty, and it throws a plain `Error` carrying the status. There is no typed predicate to narrow it with. Handle it where you would handle any other failed fetch: an `errorComponent` on the route, or a check in the handler.
|
|
170
147
|
|
|
148
|
+
## Typing
|
|
149
|
+
|
|
150
|
+
Records, filters and sorts are typed from `softr-env.d.ts`, the generated file at the app root describing every datasource registered on the application. Regenerate it with `npx @softr/sdk generate` after the data changes. With it in place, field ids autocomplete inside `fields`, each `where` handle offers only its field's operators, and `records[0].fields` carries real value types with no cast anywhere. Without it everything still runs, but field values type as `unknown` and every handle offers every operator.
|
|
151
|
+
|
|
152
|
+
What each Softr field type reads back as is in [references/field-types.md](./references/field-types.md). The two most commonly mistyped: SELECT and LINKED_RECORD values are `{ id, label }` objects, never plain strings. Render the `label`; filter by the `id`.
|
|
153
|
+
|
|
171
154
|
## References
|
|
172
155
|
|
|
173
|
-
- [references/
|
|
174
|
-
- [references/
|
|
156
|
+
- [references/query-builder.md](./references/query-builder.md): every filter operator, by field type
|
|
157
|
+
- [references/field-types.md](./references/field-types.md): the value shape every Softr field type reads back as
|
|
175
158
|
- [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor off `context` to scope a query
|
|
176
159
|
- [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): deciding who sees the page the records land on
|
|
160
|
+
- [charts-and-metrics/SKILL.md](../charts-and-metrics/SKILL.md): aggregating records instead of listing them
|