@softr/sdk 0.0.28 → 0.0.30-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +2 -618
- package/dist/codegen/fetch-metadata.js +46 -0
- package/dist/codegen/softr-env.js +1 -2
- package/dist/core/integration.d.ts +18 -1
- package/dist/core/integration.js +14 -14
- 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 +27 -23
- 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,149 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: record-mutations
|
|
3
|
+
description: >-
|
|
4
|
+
Create, update and delete records in a Softr datasource with defineMutation,
|
|
5
|
+
ds.create, ds.update and ds.delete: one declaration per operation, the data
|
|
6
|
+
payload keyed by your own field names, what each field type accepts on a
|
|
7
|
+
write, and the record each call returns. Use when submitting a form, saving an
|
|
8
|
+
edit or deleting a record, or when a write throws, writes the wrong field or
|
|
9
|
+
silently changes nothing. Writes only: fetching records is record-queries, and
|
|
10
|
+
aggregating them is charts-and-metrics.
|
|
11
|
+
metadata:
|
|
12
|
+
type: sub-skill
|
|
13
|
+
library: softr-sdk
|
|
14
|
+
library_version: 0.0.29
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineMutation` and every type come from `@softr/sdk` and can be imported anywhere. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
|
|
18
|
+
|
|
19
|
+
# Writing records
|
|
20
|
+
|
|
21
|
+
`ds` writes records to a Softr datasource, from the server only.
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
27
|
+
import { z } from 'zod'
|
|
28
|
+
import { defineMutation } from '@softr/sdk'
|
|
29
|
+
import { ds } from '@softr/sdk/server'
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
A write's input arrives from the client, so validate it. Pass a zod schema straight to `.validator`; Start accepts it and types `data` from it.
|
|
33
|
+
|
|
34
|
+
## Declare the write
|
|
35
|
+
|
|
36
|
+
A mutation declares exactly one operation, and each of the three needs its own declaration. One datasource carries as many mutations as the app needs, alongside its queries. A create mutation only works with `ds.create`; hand it to `ds.update` and the call throws `action "createProject" is declared as create, not update`.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
const createProject = defineMutation({
|
|
40
|
+
id: 'createProject',
|
|
41
|
+
datasource: '00000000-0000-0000-0000-000000000000',
|
|
42
|
+
operation: 'create',
|
|
43
|
+
fields: {
|
|
44
|
+
name: 'fZk3Qm9pLw2Xa1', // SINGLE_LINE_TEXT
|
|
45
|
+
status: 'Ru7bVx2cNq0Ed9', // SELECT, choice ids 'draft' | 'active' | 'archived'
|
|
46
|
+
dueDate: 'Xp2dLh6vKz9Fa3', // DATETIME
|
|
47
|
+
},
|
|
48
|
+
permission: { userGroup: 'LOGGED_IN_USERS' },
|
|
49
|
+
})
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The declaration follows the same rules as a read, spelled out in [record-queries](../record-queries/SKILL.md): `id` reads like a name and is unique across the app, queries counted alongside mutations; `datasource` is the opaque UUID from the table's registration, never derived from its name; the keys of `fields` are names you choose and the values are opaque field ids; `permission` declares who may run the mutation, from the same three choices, and the SDK enforces it; and the object is written inline, because passing it through a variable or a spread widens the field ids and the types stop checking.
|
|
53
|
+
|
|
54
|
+
The mutation's `fields` bound the write: `data` can only name them, and the returned record only carries them. A delete mutation declares no `fields`, and giving it some is a type error.
|
|
55
|
+
|
|
56
|
+
## Core Patterns
|
|
57
|
+
|
|
58
|
+
### Create a record
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const NewProject = z.object({ name: z.string(), dueDate: z.string() })
|
|
62
|
+
|
|
63
|
+
const addProject = createServerFn({ method: 'POST' })
|
|
64
|
+
.validator(NewProject)
|
|
65
|
+
.handler(async ({ data }) =>
|
|
66
|
+
ds.create(createProject, {
|
|
67
|
+
data: { name: data.name, dueDate: data.dueDate, status: { id: 'draft' } },
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Returns the created record as `{ id, fields }`, with `fields` keyed by the mutation's names and holding the same value shapes a read returns. Every key in `data` is optional. A field left out, or written as `null`, gets the datasource's default.
|
|
73
|
+
|
|
74
|
+
### Update a record
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const updateProject = defineMutation({
|
|
78
|
+
id: 'updateProject',
|
|
79
|
+
datasource: '00000000-0000-0000-0000-000000000000',
|
|
80
|
+
operation: 'update',
|
|
81
|
+
fields: { status: 'Ru7bVx2cNq0Ed9', dueDate: 'Xp2dLh6vKz9Fa3' },
|
|
82
|
+
permission: { userGroup: 'LOGGED_IN_USERS' },
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const archiveProject = createServerFn({ method: 'POST' })
|
|
86
|
+
.validator(z.object({ id: z.string() }))
|
|
87
|
+
.handler(async ({ data }) =>
|
|
88
|
+
ds.update(updateProject, { id: data.id, data: { status: { id: 'archived' } } }),
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The record id is its own option; keep it out of `data`. An empty `id` throws.
|
|
93
|
+
|
|
94
|
+
`update` writes the keys in `data` and leaves every other field alone, so a field you leave out is a field you are not writing. On an update, `null` clears the field, where on a create it reached the default. Returns the updated record.
|
|
95
|
+
|
|
96
|
+
### Delete a record
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
const deleteProject = defineMutation({
|
|
100
|
+
id: 'deleteProject',
|
|
101
|
+
datasource: '00000000-0000-0000-0000-000000000000',
|
|
102
|
+
operation: 'delete',
|
|
103
|
+
permission: { userGroup: 'LOGGED_IN_USERS' },
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const removeProject = createServerFn({ method: 'POST' })
|
|
107
|
+
.validator(z.object({ id: z.string() }))
|
|
108
|
+
.handler(async ({ data }) => {
|
|
109
|
+
await ds.delete(deleteProject, { id: data.id })
|
|
110
|
+
})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Resolves to nothing. A record that cannot be deleted throws.
|
|
114
|
+
|
|
115
|
+
### Edit an existing record
|
|
116
|
+
|
|
117
|
+
An edit form needs both halves: a `defineQuery` to load the record and a `defineMutation` to save it. They are separate declarations over the same table, so give both the fields the form touches, and read the loaded values off the query's names.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
const projectDetail = defineQuery({
|
|
121
|
+
id: 'projectDetail',
|
|
122
|
+
datasource: '00000000-0000-0000-0000-000000000000',
|
|
123
|
+
fields: { status: 'Ru7bVx2cNq0Ed9', dueDate: 'Xp2dLh6vKz9Fa3' },
|
|
124
|
+
permission: { userGroup: 'LOGGED_IN_USERS' },
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const project = await ds.get(projectDetail, { id })
|
|
128
|
+
await ds.update(updateProject, {
|
|
129
|
+
id,
|
|
130
|
+
data: { status: { id: project.fields.status.id } },
|
|
131
|
+
})
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
A read answers a SELECT as `{ id, label }` and a write takes `{ id }`, so pass the id through rather than the whole value.
|
|
135
|
+
|
|
136
|
+
### What a write accepts
|
|
137
|
+
|
|
138
|
+
Each key of `data` accepts exactly what its field takes (see [record-queries](../record-queries/SKILL.md#typing)):
|
|
139
|
+
|
|
140
|
+
- SELECT and LINKED_RECORD take `{ id }`. Not a bare string, and not the `{ id, label }` a read returns; the label is display data and a write does not accept it. A generated SELECT narrows the id to the field's own choices.
|
|
141
|
+
- CHECKBOX takes a boolean. Dates take ISO strings, never `Date` instances.
|
|
142
|
+
- ATTACHMENT takes `{ url }`, optionally with `filename`, `id` and `type`.
|
|
143
|
+
- A multi-entry field takes an array of the value.
|
|
144
|
+
|
|
145
|
+
## References
|
|
146
|
+
|
|
147
|
+
- [record-queries/SKILL.md](../record-queries/SKILL.md): declaring the read half of an edit form, and the codegen the write types come from. Load it alongside this skill for any screen that shows a record before changing it.
|
|
148
|
+
- [record-queries/references/field-types.md](../record-queries/references/field-types.md): the value shape every field reads back as. Load it when you are unsure what a field accepts.
|
|
149
|
+
- [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor off `context`
|
|
@@ -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
|