@softr/sdk 0.0.27 → 0.0.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/action.d.ts +30 -73
- package/dist/action.js +9 -8
- package/dist/cli.js +2 -618
- package/dist/codegen/datasource-types.js +1 -1
- package/dist/codegen/fetch-metadata.js +46 -0
- package/dist/codegen/softr-env.js +1 -2
- package/dist/core/integration.d.ts +1 -1
- package/dist/core/integration.js +2 -2
- package/dist/datasource/record.js +2 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/package.js +1 -1
- package/package.json +1 -1
- package/skills/softr-sdk/SKILL.md +14 -14
- package/skills/softr-sdk/auth/SKILL.md +1 -1
- package/skills/softr-sdk/charts-and-metrics/SKILL.md +180 -0
- package/skills/softr-sdk/charts-and-metrics/references/aggregations.md +76 -0
- package/skills/softr-sdk/current-user/SKILL.md +6 -6
- package/skills/softr-sdk/integrations/SKILL.md +9 -8
- package/skills/softr-sdk/record-mutations/SKILL.md +149 -0
- package/skills/softr-sdk/record-queries/SKILL.md +163 -0
- package/skills/softr-sdk/record-queries/references/field-types.md +63 -0
- package/skills/softr-sdk/record-queries/references/filters-and-sorting.md +146 -0
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +4 -4
- package/skills/softr-sdk/data-mutations/SKILL.md +0 -138
- package/skills/softr-sdk/reading-records/SKILL.md +0 -176
- package/skills/softr-sdk/reading-records/references/field-types.md +0 -70
- package/skills/softr-sdk/reading-records/references/query-builder.md +0 -144
|
@@ -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.
|
|
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: ['
|
|
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: ['
|
|
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
|
-
- [
|
|
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.27
|
|
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
|