@softr/sdk 0.0.28 → 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.
@@ -0,0 +1,163 @@
1
+ ---
2
+ name: record-queries
3
+ description: >-
4
+ Fetch records from a Softr datasource with defineQuery, ds.list and ds.get:
5
+ the where and orderBy callbacks, field names of your own choosing, the
6
+ 20-record default and 100-record cap, nextOffset pagination, and scoping a
7
+ read to the signed-in visitor. Use when putting datasource records on a page,
8
+ in a table, a list or a detail view, when filtering or sorting them, or when a
9
+ read comes back empty, unsorted or missing a field. Reads only: creating,
10
+ updating and deleting records is record-mutations, and aggregating them into
11
+ totals or chart rows is charts-and-metrics.
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
+ # Reading records
21
+
22
+ `ds` reads records out of a Softr datasource, from the server only.
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
+
32
+ `ds` is server-only. `defineQuery` only declares a read, so it ships on the root entrypoint and the declaration can sit anywhere.
33
+
34
+ ## Declare the read
35
+
36
+ Every read takes a query: which datasource, which fields, who it is for.
37
+
38
+ ```ts
39
+ const publishedPosts = defineQuery({
40
+ id: 'publishedPosts',
41
+ datasource: '00000000-0000-0000-0000-000000000000',
42
+ fields: {
43
+ title: 'fZk3Qm9pLw2Xa1', // SINGLE_LINE_TEXT
44
+ published: 'Ru7bVx2cNq0Ed9', // CHECKBOX
45
+ publishedAt: 'Xp2dLh6vKz9Fa3',// DATETIME
46
+ views: 'mB4kQ8yTn1sWc7', // NUMBER
47
+ },
48
+ permission: { userGroup: 'ALL_USERS' },
49
+ })
50
+ ```
51
+
52
+ - `id` is the name the person building the app sees, so `publishedPosts`, not `q1`. It must be unique across the app: no two declarations may share one, queries and mutations counted together.
53
+ - `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.
54
+ - The keys of `fields` are names you choose. The values are field ids, opaque strings the table's schema gives you: `get_table` returns them for a Softr table, `list_data_source_table_fields` for a table on a connected database. Everything downstream speaks your names: the returned records, `where`, `orderBy`.
55
+ - `permission` declares who may run the query, and the SDK enforces it:
56
+ - `{ userGroup: 'ALL_USERS' }`: everyone, signed out included.
57
+ - `{ userGroup: 'NON_LOGGED_IN_USERS' }`: signed-out visitors only.
58
+ - `{ userGroup: 'LOGGED_IN_USERS' }`: any signed-in visitor, narrowed to particular groups with `userGroupIds: ['00000000-0000-0000-0000-00000000000a']`.
59
+ - Write the object inline in the `defineQuery` call. Passing the declaration or its `fields` through a variable, or spreading one in, widens the field ids to `string` and the types stop checking.
60
+
61
+ A query 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. Declare as many queries over one datasource as the app needs, each carrying the fields its own screen touches.
62
+
63
+ ## Core Patterns
64
+
65
+ ### List records
66
+
67
+ ```ts
68
+ const listPublishedPosts = createServerFn().handler(async () => {
69
+ return await ds
70
+ .list(publishedPosts)
71
+ .where((q) => q.published.is(true))
72
+ .orderBy((q) => q.publishedAt.desc)
73
+ .limit(50)
74
+ })
75
+ ```
76
+
77
+ Returns `{ records, total, nextOffset }`. Each record is `{ id, fields }`, with `fields` keyed by the query's own names: every declared field is present, `null` when empty, and a field the query does not declare is not returned. `ds.list(publishedPosts)` with no calls chained returns the first 20 records. `limit` is clamped to 100, so more than a hundred records means paginating.
78
+
79
+ ### Filter with the `where` callback
80
+
81
+ `q` is the argument the callback is handed, not an import. It carries one handle per declared field, offering only the operators that field's type supports, plus `$and` and `$or`:
82
+
83
+ ```ts
84
+ await ds.list(publishedPosts).where((q) =>
85
+ q.$and(
86
+ q.published.is(true),
87
+ q.views.gte(100),
88
+ q.$or(q.title.contains('cafe'), q.title.startsWith('The')),
89
+ ),
90
+ )
91
+ ```
92
+
93
+ Calling `.where` more than once ands the filters together, which is how a fixed scope and a user-supplied filter combine. Only the query's own fields can be filtered or sorted on, so a field you need in a `where` belongs in `fields` even when the page never displays it. Load [references/filters-and-sorting.md](./references/filters-and-sorting.md) when you need an operator this page does not show, or when a filter is rejected.
94
+
95
+ ### Sort
96
+
97
+ ```ts
98
+ .orderBy((q) => q.publishedAt.desc)
99
+ .orderBy((q) => [q.views.desc, q.title.asc]) // array for tie-breakers
100
+ ```
101
+
102
+ ### Get one record
103
+
104
+ ```ts
105
+ const post = await ds.get(publishedPosts, { id: postId })
106
+ if (post === null) throw notFound()
107
+ ```
108
+
109
+ A record that does not exist comes back as `null`, not an error. A chained `.where` narrows which record the id may resolve to, and a record that does not match reads as `null` too:
110
+
111
+ ```ts
112
+ const post = await ds
113
+ .get(publishedPosts, { id: postId })
114
+ .where((q) => q.published.is(true))
115
+ ```
116
+
117
+ 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.
118
+
119
+ ### Paginate
120
+
121
+ ```ts
122
+ const first = await ds.list(publishedPosts).limit(50)
123
+ if (first.nextOffset !== null) {
124
+ const second = await ds.list(publishedPosts).limit(50).offset(first.nextOffset)
125
+ }
126
+ ```
127
+
128
+ `nextOffset` is `null` when there are no more pages. `total` is the count of all matching records, not the count returned.
129
+
130
+ ### Scope the read to the visitor
131
+
132
+ ```ts
133
+ const myInvoices = defineQuery({
134
+ id: 'myInvoices',
135
+ datasource: '00000000-0000-0000-0000-000000000001',
136
+ fields: { owner: 'Gq5wNt8rJy3Hb1', amount: 'Vk9cXm2pSz6Ld4' },
137
+ permission: { userGroup: 'LOGGED_IN_USERS' },
138
+ })
139
+
140
+ const listMyInvoices = createServerFn().handler(async ({ context }) => {
141
+ if (!context.user) throw new Error('Not signed in')
142
+ const userId = context.user.id
143
+ return await ds.list(myInvoices).where((q) => q.owner.is(userId))
144
+ })
145
+ ```
146
+
147
+ The SDK enforces `permission`, so a visitor outside the declared audience never reaches the records. Which of those records belong to one visitor is a separate question, and the `where` above answers it: a group says nothing about who owns a row, so a per-visitor read filters on `context.user`. See [current-user](../current-user/SKILL.md) for how the visitor gets onto `context`.
148
+
149
+ A read the datasource turns down 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.
150
+
151
+ ## Typing
152
+
153
+ `softr-env.d.ts`, the generated file at the app root, types records, filters and sorts from every registered datasource. It follows the data on its own: nothing to run, nothing to edit. It checks the ids you wrote rather than handing them to you, so ask the schema for a field id and open the file only when a declaration will not compile.
154
+
155
+
156
+ ## References
157
+
158
+ - [references/filters-and-sorting.md](./references/filters-and-sorting.md): every filter operator by field type, group nesting and the sort handles. Load it when the operator you want is not in this file, or when a filter is rejected.
159
+ - [references/field-types.md](./references/field-types.md): the value shape every Softr field type reads back as. Load it when rendering a field whose shape you are unsure of, or when a value is not the string you expected.
160
+ - [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor off `context` to scope a query
161
+ - [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): deciding who sees the page the records land on
162
+ - [charts-and-metrics/SKILL.md](../charts-and-metrics/SKILL.md): aggregating records instead of listing them
163
+ - [record-mutations/SKILL.md](../record-mutations/SKILL.md): creating, updating and deleting them
@@ -0,0 +1,63 @@
1
+ # Softr field types and what their values look like
2
+
3
+ The generated `softr-env.d.ts` is the authority for your own tables: it types every field of every registered datasource, choice ids included, with the field's name and constraints in a docstring. This reference is the general mapping behind it, for reading a record's values without opening that file.
4
+
5
+ Three rules cover nullability:
6
+
7
+ - Every field reads back present, `null` when it is empty. Nothing is optional.
8
+ - A multi-entry field is an array, empty rather than `null`.
9
+ - A `CHECKBOX` is a boolean, `false` rather than `null`.
10
+
11
+ ```ts
12
+ // Strings
13
+ SINGLE_LINE_TEXT, LONG_TEXT, ADDRESS, LOCATION, BUTTON, RECORD_ID: string
14
+ EMAIL, PHONE, URL: string
15
+ DATETIME: string // ISO timestamp, or an ISO date at date precision
16
+ CREATED_AT, UPDATED_AT: string
17
+ TIME: string // '09:30:00'
18
+ CREATED_BY, UPDATED_BY: string
19
+
20
+ // Numbers
21
+ NUMBER, PERCENT, CURRENCY, PROGRESS, DURATION, COUNT, RATING, AUTONUMBER: number
22
+
23
+ // Booleans
24
+ CHECKBOX: boolean
25
+
26
+ // Objects. These are NOT plain strings
27
+ SELECT: { id: string; label: string | null } // generated with literal id and label unions
28
+ LINKED_RECORD: { id: string; label: string | null } // label is the linked record's primary field
29
+ DATE_RANGE: { from: string; to: string }
30
+ USER: { id: string; name: string; email: string; avatarUrl: string | null }
31
+ ATTACHMENT: {
32
+ id: string | null
33
+ filename: string | null
34
+ type: string | null
35
+ url: string
36
+ expiresAt: string | null
37
+ size?: number
38
+ thumbnails?: Array<{ size: 'small' | 'medium' | 'large'; url: string }>
39
+ }
40
+
41
+ // Computed fields read as whatever they return
42
+ FORMULA, LOOKUP, ROLLUP: the returned field's shape, resolved in softr-env.d.ts
43
+ ```
44
+
45
+ Every scalar above is `| null` on the record unless the field allows multiple entries, in which case the value is an `Array<...>` of the scalar.
46
+
47
+ ## The non-obvious ones
48
+
49
+ **SELECT and LINKED_RECORD** are the most commonly mistyped. They are never a plain string, always an `{ id, label }` object. To display the value, use `.label`. To filter or compare, use `.id`. A generated SELECT narrows both to the field's own choices as literal unions.
50
+
51
+ ```tsx
52
+ // Correct
53
+ <td>{record.fields.status?.label}</td>
54
+
55
+ // Wrong: status is not a string
56
+ <td>{record.fields.status}</td>
57
+ ```
58
+
59
+ **DURATION** is seconds, whatever format the field displays in.
60
+
61
+ **DATETIME at date precision** is generated as a `DATE` field and holds `'2026-03-14'`, not a timestamp.
62
+
63
+ **LOOKUP and FORMULA** take the shape of what they resolve to, so two lookups on one table can have entirely different types. Read the generated type rather than guessing from the field name.
@@ -0,0 +1,146 @@
1
+ # Filters and sorting
2
+
3
+ Every filter and sort is built inside the callback `where` or `orderBy` hands you. A filter built any other way is rejected at request time.
4
+
5
+ - [Shape](#shape)
6
+ - [Operators by field type](#operators-by-field-type): [text](#text-fields), [number](#number-fields), [checkbox](#checkbox-fields), [date](#date-fields), [choice](#choice-fields), [multi-value](#multi-value-fields), [user](#user-fields), [attachment and date range](#attachment-and-date-range-fields)
7
+ - [Combining](#combining)
8
+ - [Sorting](#sorting)
9
+ - [Errors](#errors)
10
+
11
+ ## Shape
12
+
13
+ ```ts
14
+ await ds
15
+ .list(publishedPosts)
16
+ .where((q) => q.status.is('active')) // one filter, or a $and/$or of filters
17
+ .orderBy((q) => q.title.asc) // or an array of handles
18
+ .limit(20) // default 20, clamped to 100
19
+ .offset(null) // a previous result's nextOffset
20
+ ```
21
+
22
+ A `where` callback returns exactly one value. For more than one condition, wrap them in `q.$and(...)` or `q.$or(...)`, or call `.where` again: repeated calls are anded.
23
+
24
+ ## Operators by field type
25
+
26
+ The handle for each field carries the operators its declared type supports, taken from the generated `softr-env.d.ts`.
27
+
28
+ ### Text fields
29
+
30
+ `SINGLE_LINE_TEXT`, `LONG_TEXT`, `URL`, `EMAIL`, `PHONE`, `ADDRESS`, `LOCATION`, `TIME`, `BUTTON`, `RECORD_ID`.
31
+
32
+ | Call | Meaning |
33
+ | --- | --- |
34
+ | `.is(value: string)` | Exactly equal |
35
+ | `.isNot(value: string)` | Not equal |
36
+ | `.contains(value: string)` | Substring match |
37
+ | `.startsWith(value: string)` | Prefix match |
38
+ | `.endsWith(value: string)` | Suffix match |
39
+ | `.isOneOf(values: string[])` | Equal to any of |
40
+ | `.isNoneOf(values: string[])` | Equal to none of |
41
+ | `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
42
+
43
+ ### Number fields
44
+
45
+ `NUMBER`, `PERCENT`, `CURRENCY`, `PROGRESS`, `RATING`, `DURATION`, `COUNT`, `AUTONUMBER`.
46
+
47
+ | Call | Meaning |
48
+ | --- | --- |
49
+ | `.is(value: number)` / `.isNot(value: number)` | Equal / not equal |
50
+ | `.gt(value)` / `.gte(value)` / `.lt(value)` / `.lte(value)` | Comparisons |
51
+ | `.between(start: number, end: number)` | Inclusive range |
52
+ | `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
53
+
54
+ ### Checkbox fields
55
+
56
+ | Call | Meaning |
57
+ | --- | --- |
58
+ | `.is(value: boolean)` / `.isNot(value: boolean)` | Equal / not equal |
59
+ | `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
60
+
61
+ ### Date fields
62
+
63
+ `DATE`, `DATETIME`, `CREATED_AT`, `UPDATED_AT`.
64
+
65
+ | Call | Meaning |
66
+ | --- | --- |
67
+ | `.is(value: string)` / `.isNot(value: string)` | On / not on that date |
68
+ | `.gt(value)` / `.gte(value)` | After / on or after |
69
+ | `.lt(value)` / `.lte(value)` | Before / on or before |
70
+ | `.between(start, end)` | Inclusive range |
71
+ | `.isNotBetween(start, end)` | Outside the range |
72
+ | `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
73
+
74
+ Every value is an ISO date string, either `'2026-01-31'` or a full timestamp. A `Date` instance is not accepted. Call `.toISOString()` first.
75
+
76
+ ### Choice fields
77
+
78
+ `SELECT`, `LINKED_RECORD`, `CREATED_BY`, `UPDATED_BY`.
79
+
80
+ | Call | Meaning |
81
+ | --- | --- |
82
+ | `.is(value)` / `.isNot(value)` | Equal / not equal |
83
+ | `.isOneOf(values)` / `.isNoneOf(values)` | Any of / none of |
84
+ | `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
85
+
86
+ Values are choice ids, never labels. A generated SELECT narrows the argument to the field's own choice ids, so a label or a typo is a type error. A LINKED_RECORD compares against the linked record's id.
87
+
88
+ ### Multi-value fields
89
+
90
+ Any generated type with a `[]` suffix: multi-selects, multi-entry linked records.
91
+
92
+ | Call | Meaning |
93
+ | --- | --- |
94
+ | `.is(values: string[])` | Exactly this set |
95
+ | `.isOneOf(values: string[])` | Contains at least one of |
96
+ | `.isNoneOf(values: string[])` | Contains none of |
97
+ | `.hasAllOf(values: string[])` | Contains all of |
98
+ | `.isEmpty()` / `.isNotEmpty()` | Empty / non-empty |
99
+
100
+ Values are the members' ids, as with choice fields.
101
+
102
+ ### User fields
103
+
104
+ `USER` offers `.is`, `.isNot`, `.contains`, `.isEmpty` and `.isNotEmpty`.
105
+
106
+ ### Attachment and date range fields
107
+
108
+ `ATTACHMENT` and `DATE_RANGE` have no single value to compare, so they offer only `.isEmpty()` and `.isNotEmpty()`.
109
+
110
+ ## Combining
111
+
112
+ ```ts
113
+ q.$and(...filters) // all must match
114
+ q.$or(...filters) // any may match
115
+ ```
116
+
117
+ Both take filters or nested groups:
118
+
119
+ ```ts
120
+ .where((q) =>
121
+ q.$and(
122
+ q.status.is('active'),
123
+ q.$or(q.amount.gte(1000), q.title.contains('priority')),
124
+ ),
125
+ )
126
+ ```
127
+
128
+ Nesting goes two levels, a group inside a group. A third level throws `Cannot nest filter groups more than three levels deep.` Flatten by distributing the outer condition rather than nesting further.
129
+
130
+ ## Sorting
131
+
132
+ ```ts
133
+ .orderBy((q) => q.title.asc)
134
+ .orderBy((q) => [q.publishedAt.desc, q.title.asc])
135
+ ```
136
+
137
+ Each field carries `.asc` and `.desc` handles and nothing else. An array applies sorts in order. Repeated `.orderBy` calls append.
138
+
139
+ ## Errors
140
+
141
+ | Message | Cause |
142
+ | --- | --- |
143
+ | `a filter must come from the callback a read hands you` | A `where` value built as a plain object |
144
+ | `a sort must come from the callback a read hands you` | An `orderBy` value not from the callback's `.asc` / `.desc` handles |
145
+ | `Cannot nest filter groups more than three levels deep.` | A group nested one level too far |
146
+ | A `TypeError` reading an operator off `undefined` | Filtering or sorting on a field the query does not declare |
@@ -12,7 +12,7 @@ description: >-
12
12
  metadata:
13
13
  type: sub-skill
14
14
  library: softr-sdk
15
- library_version: 0.0.28
15
+ library_version: 0.0.29
16
16
  ---
17
17
 
18
18
  **Boundary.** `defineVisibility`, the error predicates 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.
@@ -57,7 +57,7 @@ defineVisibility({ userGroup: 'NON_LOGGED_IN_USERS' }) // signed-out only: logi
57
57
  defineVisibility({ userGroup: 'LOGGED_IN_USERS' }) // any signed-in visitor
58
58
  defineVisibility({ // those groups only
59
59
  userGroup: 'LOGGED_IN_USERS',
60
- userGroupIds: ['b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d'],
60
+ userGroupIds: ['00000000-0000-0000-0000-00000000000a'],
61
61
  })
62
62
  ```
63
63
 
@@ -117,7 +117,7 @@ import { defineVisibility, isAccessError, isForbiddenError } from '@softr/sdk'
117
117
 
118
118
  const visibility = defineVisibility({
119
119
  userGroup: 'LOGGED_IN_USERS',
120
- userGroupIds: ['b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d'],
120
+ userGroupIds: ['00000000-0000-0000-0000-00000000000a'],
121
121
  })
122
122
 
123
123
  export const Route = createFileRoute('/_app/billing')({
@@ -156,5 +156,5 @@ Reach for `assert()` when the route wants the error thrown for it, and `check(co
156
156
  ## References
157
157
 
158
158
  - [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor a guard has just admitted
159
- - [reading-records/SKILL.md](../reading-records/SKILL.md): a guard covers the page, not the server function behind it
159
+ - [record-queries/SKILL.md](../record-queries/SKILL.md): a guard covers the page, not the server function behind it
160
160
  - [auth/SKILL.md](../auth/SKILL.md): `NON_LOGGED_IN_USERS` and the sign-in flow it fronts
@@ -1,138 +0,0 @@
1
- ---
2
- name: data-mutations
3
- description: >-
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.
8
- metadata:
9
- type: sub-skill
10
- library: softr-sdk
11
- library_version: 0.0.28
12
- ---
13
-
14
- **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.
15
-
16
- # Writing records
17
-
18
- `ds` writes records to a Softr datasource, from the server only.
19
-
20
- ## Setup
21
-
22
- ```ts
23
- import { createServerFn } from '@tanstack/react-start'
24
- import { z } from 'zod'
25
- import { ds } from '@softr/sdk/server'
26
- import { q } from '@softr/sdk'
27
- ```
28
-
29
- Pass a zod schema straight to `.validator`. Start accepts it and types `data` from it.
30
-
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
-
33
- **The datasource id must be a string literal at the call site.**
34
-
35
- ## Core Patterns
36
-
37
- ### Create a record
38
-
39
- ```ts
40
- const NewProject = z.object({ name: z.string(), status: z.string() })
41
-
42
- const createProject = createServerFn({ method: 'POST' })
43
- .validator(NewProject)
44
- .handler(async ({ data }) =>
45
- ds.create('8ffb4814-b028-4496-b679-7face2167ebc', {
46
- data,
47
- select: q.select({ name: 'fZk3Qm9pLw2Xa1', status: 'Ru7bVx2cNq0Ed9' }),
48
- }),
49
- )
50
- ```
51
-
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.
57
-
58
- ### Update a record
59
-
60
- ```ts
61
- const Rename = z.object({ id: z.string(), name: z.string() })
62
-
63
- const renameProject = createServerFn({ method: 'POST' })
64
- .validator(Rename)
65
- .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
- }),
71
- )
72
- ```
73
-
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.)
81
-
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.
94
-
95
- ### Delete a record
96
-
97
- ```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))
103
- ```
104
-
105
- **`delete` takes the record id as a positional argument**, unlike `create` and `update`, which take an options object. It resolves to nothing.
106
-
107
- ### Naming fields with `select`
108
-
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.
110
-
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.
112
-
113
- ### Writing on behalf of the visitor
114
-
115
- Stamping a record with who created it, or scoping an update to the person making it, reads the visitor off the handler's `context`. That is [current-user](../current-user/SKILL.md)'s subject, including the null guest and how Start types it. Load that skill rather than looking for it here.
116
-
117
- ## Failure Modes
118
-
119
- **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
-
121
- **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.
122
-
123
- **A write that sends `fields`.** The option is `data`. Only typecheck catches this, so it survives any run that does not typecheck.
124
-
125
- **A key missing from `select`.** The key is not translated, so the field it was meant for is not the field written. Check the map covers every key rather than trusting the call to complain.
126
-
127
- **A write with no `select` and your own key names.** Without a map, `data` must be keyed by raw field ids.
128
-
129
- **Passing an options object to `delete`.** The second argument is the id itself. `delete` throws when the record cannot be deleted.
130
-
131
- **Expecting `select` on a read-then-write round trip.** `ds.get` takes no `select`, so a record fetched by id answers with raw field ids. Load `reading-records` before pairing a read with a write.
132
-
133
- **Reading the visitor from somewhere other than `context`.** There is nothing to import. See [current-user](../current-user/SKILL.md).
134
-
135
- ## References
136
-
137
- - [reading-records/SKILL.md](../reading-records/SKILL.md): fetching the record a write is about to change
138
- - [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor off `context` to stamp or scope a write