@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.
Files changed (62) hide show
  1. package/dist/action.d.ts +195 -0
  2. package/dist/action.js +21 -0
  3. package/dist/assertions.js +1 -7
  4. package/dist/auth/isomorphic.d.ts +1 -2
  5. package/dist/auth/methods/authenticator.d.ts +80 -0
  6. package/dist/auth/methods/authenticator.js +175 -0
  7. package/dist/auth/methods/code.d.ts +40 -0
  8. package/dist/auth/methods/code.js +74 -0
  9. package/dist/auth/methods/magicLink.d.ts +24 -0
  10. package/dist/auth/methods/magicLink.js +53 -0
  11. package/dist/auth/methods/password.d.ts +19 -5
  12. package/dist/auth/methods/password.js +36 -15
  13. package/dist/auth/methods/signup.d.ts +22 -14
  14. package/dist/auth/methods/signup.js +48 -54
  15. package/dist/auth/methods/social.d.ts +35 -0
  16. package/dist/auth/methods/social.js +58 -0
  17. package/dist/auth/methods/utils.d.ts +1 -1
  18. package/dist/auth/permissions.d.ts +7 -2
  19. package/dist/auth/platform.js +185 -7
  20. package/dist/auth/server.d.ts +15 -0
  21. package/dist/auth/server.js +16 -1
  22. package/dist/auth/session.js +35 -2
  23. package/dist/cli.d.ts +1 -0
  24. package/dist/cli.js +23 -0
  25. package/dist/codegen/datasource-types.js +471 -0
  26. package/dist/codegen/fetch-metadata.js +46 -0
  27. package/dist/codegen/softr-env.js +102 -0
  28. package/dist/codegen/user-groups.js +11 -0
  29. package/dist/core/integration.js +1 -1
  30. package/dist/datasource/chart.d.ts +196 -0
  31. package/dist/datasource/chart.js +289 -0
  32. package/dist/datasource/datasource.d.ts +16 -0
  33. package/dist/datasource/datasource.js +17 -0
  34. package/dist/datasource/fields.d.ts +122 -0
  35. package/dist/datasource/fields.js +82 -0
  36. package/dist/datasource/filter.d.ts +30 -0
  37. package/dist/datasource/filter.js +146 -0
  38. package/dist/datasource/record.d.ts +138 -0
  39. package/dist/datasource/record.js +258 -0
  40. package/dist/index.d.ts +3 -5
  41. package/dist/index.js +2 -2
  42. package/dist/package.js +5 -0
  43. package/dist/routes/visibility.d.ts +1 -1
  44. package/dist/server.d.ts +1 -1
  45. package/dist/server.js +1 -1
  46. package/package.json +31 -8
  47. package/skills/softr-sdk/SKILL.md +11 -7
  48. package/skills/softr-sdk/auth/SKILL.md +222 -36
  49. package/skills/softr-sdk/charts-and-metrics/SKILL.md +178 -0
  50. package/skills/softr-sdk/charts-and-metrics/references/aggregations.md +76 -0
  51. package/skills/softr-sdk/current-user/SKILL.md +1 -1
  52. package/skills/softr-sdk/data-mutations/SKILL.md +76 -56
  53. package/skills/softr-sdk/reading-records/SKILL.md +77 -93
  54. package/skills/softr-sdk/reading-records/references/field-types.md +41 -48
  55. package/skills/softr-sdk/reading-records/references/query-builder.md +66 -70
  56. package/skills/softr-sdk/routes-and-visibility/SKILL.md +1 -1
  57. package/dist/core/datasource.d.ts +0 -31
  58. package/dist/core/datasource.js +0 -226
  59. package/dist/core/query.d.ts +0 -354
  60. package/dist/core/query.js +0 -528
  61. package/dist/core/utils.js +0 -14
  62. 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.24
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 ds.delete from @softr/sdk/server, the data
6
- payload key, select field aliasing on the way in and out, and what each call
7
- returns or throws. Writes only. Reads, metrics and chart data are not covered.
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.24
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`. Start accepts it and types `data` from it.
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
- Every `ds` call names its datasource by an 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 never derived from the table's name. Field ids are opaque too (`fZk3Qm9pLw2Xa1`, not `fld_<name>`) and come from the table's schema.
32
+ ## Declare the write
32
33
 
33
- **The datasource id must be a string literal at the call site.**
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(), status: z.string() })
59
+ const NewProject = z.object({ name: z.string(), dueDate: z.string() })
41
60
 
42
- const createProject = createServerFn({ method: 'POST' })
61
+ const addProject = createServerFn({ method: 'POST' })
43
62
  .validator(NewProject)
44
63
  .handler(async ({ data }) =>
45
- ds.create('8ffb4814-b028-4496-b679-7face2167ebc', {
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
- Resolves to `{ id, fields }`, and on a Softr table `fields` holds **only the keys you wrote** — not the rest of the record. See "What a write answers with" below.
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 Rename = z.object({ id: z.string(), name: z.string() })
62
-
63
- const renameProject = createServerFn({ method: 'POST' })
64
- .validator(Rename)
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('8ffb4814-b028-4496-b679-7face2167ebc', {
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, so keep it out of `data`.
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
- So the answer is a receipt, not a replacement for what the page holds. Putting it where a loader-fetched record was blanks every column you did not write. Two honest ways to show the result:
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 RecordId = z.object({ id: z.string() })
99
-
100
- const deleteProject = createServerFn({ method: 'POST' })
101
- .validator(RecordId)
102
- .handler(async ({ data }) => ds.delete('8ffb4814-b028-4496-b679-7face2167ebc', data.id))
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
- **`delete` takes the record id as a positional argument**, unlike `create` and `update`, which take an options object. It resolves to nothing.
111
+ Resolves to nothing. A record that cannot be deleted throws.
106
112
 
107
- ### Naming fields with `select`
113
+ ### What a write accepts
108
114
 
109
- `select` works in both directions on a write. Going in, it turns your names into Softr field ids. Coming back, it turns the returned field ids into your names. Without it, `data` has to be keyed by raw field ids and the returned record answers with them too.
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
- The map has to cover every key in `data`. A key it does not map is not translated, so the write does not land on the field you meant.
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: ds.list and
5
- ds.get from @softr/sdk/server, the q query builder (q.text, q.number,
6
- q.boolean, q.date, q.array, q.and, q.or, q.asc, q.desc), q.select field
7
- aliasing, filtering, sorting, the 20-record default and 100-record cap, and
8
- nextOffset pagination. Reads only. Mutations, workflows, integrations, metrics
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.24
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
- Every `ds` call names its datasource by an 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 it 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.
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
- **The datasource id must be a string literal at the call site.** Do not assign it to a variable and pass the variable. Write the UUID directly as the first argument:
31
+ ## Declare the read
33
32
 
34
- ```ts
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
- import type { SoftrRecord } from '@softr/sdk'
51
-
52
- type Product = SoftrRecord<{
53
- name: string // SINGLE_LINE_TEXT
54
- price: number | null // CURRENCY
55
- category: { id: string; label: string } // SELECT — not a string
56
- supplier: { id: string; label: string } // LINKED_RECORD — not a string
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
- `SoftrRecord<Fields>` is shorthand for `{ id: string; fields: Fields }` — the `Fields` type parameter constrains every value to `JsonValue`, so TypeScript rejects non-serializable types like `Date` objects.
61
-
62
- Cast at the `ds.list` call using the `ListResult` generic so the type flows through the loader automatically:
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
- ```ts
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.list('8ffb4814-b028-4496-b679-7face2167ebc', {
81
- select: q.select({
82
- title: 'fZk3Qm9pLw2Xa1',
83
- author: 'mB4kQ8yTn1sWc7',
84
- published: 'Ru7bVx2cNq0Ed9',
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 }`. `ds.list(POSTS)` on its own returns the first 20 records with their raw field ids. `limit` defaults to 20 and is clamped to 100. A larger number is silently reduced, so more than a hundred records means paginating.
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
- ### Name the fields with `select`
73
+ ### Filter with the `where` callback
99
74
 
100
- Softr field ids are opaque strings (`fZk3Qm9pLw2Xa1`), copied from the table's schema. `q.select` maps your own names onto them, and those names are what the rest of the call speaks:
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
- const select = q.select({ title: 'fZk3Qm9pLw2Xa1', views: 'Ru7bVx2cNq0Ed9' })
104
-
105
- await ds.list('8ffb4814-b028-4496-b679-7face2167ebc', {
106
- select,
107
- where: q.number('views').gt(100), // alias, not Ru7bVx2cNq0Ed9
108
- orderBy: q.asc('title'), // alias here too
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
- Once `select` is present it is the whole vocabulary. The returned record carries exactly those keys, and any field you filter or sort on must appear in the map.
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
- ### Get one record
89
+ ### Sort
116
90
 
117
91
  ```ts
118
- const post = await ds.get('8ffb4814-b028-4496-b679-7face2167ebc', { id: postId })
119
- if (post === null) throw notFound()
92
+ .orderBy((q) => q.publishedAt.desc)
93
+ .orderBy((q) => [q.views.desc, q.title.asc]) // array for tie-breakers
120
94
  ```
121
95
 
122
- A record that does not exist comes back as `null`, not an error.
96
+ ### Get one record
123
97
 
124
- The type also accepts `{ where }` instead of `{ id }`, but there is no way to pass a `select` alongside it, so the filter cannot be translated and the call throws. Fetch one record by id, or use `ds.list` with a `limit` of 1 when you need to find it by a field.
98
+ ```ts
99
+ const post = await ds.get(publishedPosts, { id: postId })
100
+ if (post === null) throw notFound()
101
+ ```
125
102
 
126
- ### Filter with `q`
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
- where: q.and(
130
- q.text('status').is('active'),
131
- q.number('amount').gte(100),
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
- Five field types, `q.text`, `q.number`, `q.boolean`, `q.date` and `q.array`, combined with `q.and` and `q.or`. The full operator matrix is in [references/query-builder.md](./references/query-builder.md).
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('8ffb4814-b028-4496-b679-7face2167ebc', { limit: 50 })
116
+ const first = await ds.list(publishedPosts).limit(50)
145
117
  if (first.nextOffset !== null) {
146
- const second = await ds.list('8ffb4814-b028-4496-b679-7face2167ebc', { limit: 50, offset: first.nextOffset })
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('e4b7c2a1-9d3f-4e68-b05a-7f1c6d2e8a93', {
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/field-types.md](./references/field-types.md): TypeScript shape for every Softr field type
174
- - [references/query-builder.md](./references/query-builder.md): every `q` field type and operator
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