@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.
@@ -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.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