@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
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: data-mutations
|
|
3
|
-
description: >-
|
|
4
|
-
Create, update and delete records in a Softr datasource from inside a server
|
|
5
|
-
function: ds.create, ds.update and ds.delete from @softr/sdk/server, the data
|
|
6
|
-
payload key, select field aliasing on the way in and out, and what each call
|
|
7
|
-
returns or throws. Writes only. Reads, metrics and chart data are not covered.
|
|
8
|
-
metadata:
|
|
9
|
-
type: sub-skill
|
|
10
|
-
library: softr-sdk
|
|
11
|
-
library_version: 0.0.28
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `q` and every type come from `@softr/sdk` and can be imported anywhere. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
|
|
15
|
-
|
|
16
|
-
# Writing records
|
|
17
|
-
|
|
18
|
-
`ds` writes records to a Softr datasource, from the server only.
|
|
19
|
-
|
|
20
|
-
## Setup
|
|
21
|
-
|
|
22
|
-
```ts
|
|
23
|
-
import { createServerFn } from '@tanstack/react-start'
|
|
24
|
-
import { z } from 'zod'
|
|
25
|
-
import { ds } from '@softr/sdk/server'
|
|
26
|
-
import { q } from '@softr/sdk'
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
Pass a zod schema straight to `.validator`. Start accepts it and types `data` from it.
|
|
30
|
-
|
|
31
|
-
Every `ds` call names its datasource by an opaque UUID from the table's registration on the application (Softr Studio's Data tab, or the `register_application_data_source` tool when an agent does it). It is never derived from the table's name. Field ids are opaque too (`fZk3Qm9pLw2Xa1`, not `fld_<name>`) and come from the table's schema.
|
|
32
|
-
|
|
33
|
-
**The datasource id must be a string literal at the call site.**
|
|
34
|
-
|
|
35
|
-
## Core Patterns
|
|
36
|
-
|
|
37
|
-
### Create a record
|
|
38
|
-
|
|
39
|
-
```ts
|
|
40
|
-
const NewProject = z.object({ name: z.string(), status: z.string() })
|
|
41
|
-
|
|
42
|
-
const createProject = createServerFn({ method: 'POST' })
|
|
43
|
-
.validator(NewProject)
|
|
44
|
-
.handler(async ({ data }) =>
|
|
45
|
-
ds.create('8ffb4814-b028-4496-b679-7face2167ebc', {
|
|
46
|
-
data,
|
|
47
|
-
select: q.select({ name: 'fZk3Qm9pLw2Xa1', status: 'Ru7bVx2cNq0Ed9' }),
|
|
48
|
-
}),
|
|
49
|
-
)
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
Resolves to `{ id, fields }`, and on a Softr table `fields` holds **only the keys you wrote** — not the rest of the record. See "What a write answers with" below.
|
|
53
|
-
|
|
54
|
-
**The payload key is `data`, not `fields`.** A record answers with `fields`, which is where the confusion comes from, but the option you pass is `data`. `CreateOptions` requires it, so a call keyed by `fields` does not type-check.
|
|
55
|
-
|
|
56
|
-
**`data` speaks whatever `select` maps.** The keys above are the schema's own names because `select` maps them to field ids, which is why the validated input can go straight through. With no `select`, `data` has to be keyed by raw field ids instead.
|
|
57
|
-
|
|
58
|
-
### Update a record
|
|
59
|
-
|
|
60
|
-
```ts
|
|
61
|
-
const Rename = z.object({ id: z.string(), name: z.string() })
|
|
62
|
-
|
|
63
|
-
const renameProject = createServerFn({ method: 'POST' })
|
|
64
|
-
.validator(Rename)
|
|
65
|
-
.handler(async ({ data }) =>
|
|
66
|
-
ds.update('8ffb4814-b028-4496-b679-7face2167ebc', {
|
|
67
|
-
id: data.id,
|
|
68
|
-
data: { name: data.name },
|
|
69
|
-
select: q.select({ name: 'fZk3Qm9pLw2Xa1' }),
|
|
70
|
-
}),
|
|
71
|
-
)
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
The record id is its own option, so keep it out of `data`.
|
|
75
|
-
|
|
76
|
-
`update` writes the keys in `data` and leaves every other field alone, so a field you leave out is a field you are not writing. An empty `id` throws.
|
|
77
|
-
|
|
78
|
-
### What a write answers with
|
|
79
|
-
|
|
80
|
-
`create` and `update` resolve to `{ id, fields }`, but `fields` is **not the record**. On a Softr table it carries only the keys you sent, aliased through `select`; a status change answers with `{ id, fields: { status } }` and nothing else. (Other datasource types may answer with more. Rely on neither.)
|
|
81
|
-
|
|
82
|
-
So the answer is a receipt, not a replacement for what the page holds. Putting it where a loader-fetched record was blanks every column you did not write. Two honest ways to show the result:
|
|
83
|
-
|
|
84
|
-
- **A quick change to one field** — a status select, a card dragged to another column, an archive toggle — is optimistic: change that one field in your own state immediately, keep the rest of the record as the loader gave it, save in the background, and on a rejected write restore the old value with a clear message. Do not `router.invalidate()` or re-fetch after a successful write; the local change is already what the user sees.
|
|
85
|
-
- **A saved form, or anything that changes several fields**, refreshes the route's data — `router.invalidate()`, see `@tanstack/router-core#router-core/data-loading` — so the list or detail re-reads through its own `select`.
|
|
86
|
-
|
|
87
|
-
Do not re-read the one record with `ds.get` for either: it takes no `select`, so it answers with raw field ids.
|
|
88
|
-
|
|
89
|
-
### Writing a choice field
|
|
90
|
-
|
|
91
|
-
A `SELECT` or `LINKED_RECORD` field reads back as `{ id, label }` (see [field-types](../reading-records/references/field-types.md)). Writing one accepts the option's `id`, its `label`, or `{ id, label }`, matched case-insensitively.
|
|
92
|
-
|
|
93
|
-
**A value that matches no option is not an error — it silently adds a new option to the field.** A typo'd label leaves a junk choice in the schema. So write `{ label }` (or the `{ id, label }` you read), copy labels from the table's schema verbatim, and never invent one.
|
|
94
|
-
|
|
95
|
-
### Delete a record
|
|
96
|
-
|
|
97
|
-
```ts
|
|
98
|
-
const RecordId = z.object({ id: z.string() })
|
|
99
|
-
|
|
100
|
-
const deleteProject = createServerFn({ method: 'POST' })
|
|
101
|
-
.validator(RecordId)
|
|
102
|
-
.handler(async ({ data }) => ds.delete('8ffb4814-b028-4496-b679-7face2167ebc', data.id))
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
**`delete` takes the record id as a positional argument**, unlike `create` and `update`, which take an options object. It resolves to nothing.
|
|
106
|
-
|
|
107
|
-
### Naming fields with `select`
|
|
108
|
-
|
|
109
|
-
`select` works in both directions on a write. Going in, it turns your names into Softr field ids. Coming back, it turns the returned field ids into your names. Without it, `data` has to be keyed by raw field ids and the returned record answers with them too.
|
|
110
|
-
|
|
111
|
-
The map has to cover every key in `data`. A key it does not map is not translated, so the write does not land on the field you meant.
|
|
112
|
-
|
|
113
|
-
### Writing on behalf of the visitor
|
|
114
|
-
|
|
115
|
-
Stamping a record with who created it, or scoping an update to the person making it, reads the visitor off the handler's `context`. That is [current-user](../current-user/SKILL.md)'s subject, including the null guest and how Start types it. Load that skill rather than looking for it here.
|
|
116
|
-
|
|
117
|
-
## Failure Modes
|
|
118
|
-
|
|
119
|
-
**A datasource id built from a name.** `'ds_projects'`, the table id, or the table's name glued to a uuid are not datasource ids. The id is the opaque UUID assigned when the table was registered on the application; copy it from there. Anything else fails at request time as not found or forbidden.
|
|
120
|
-
|
|
121
|
-
**A datasource id stored in a variable.** Pass the UUID as a string literal directly at the call site. The lint rule `@softr/ds-no-computed-id` catches this at development time.
|
|
122
|
-
|
|
123
|
-
**A write that sends `fields`.** The option is `data`. Only typecheck catches this, so it survives any run that does not typecheck.
|
|
124
|
-
|
|
125
|
-
**A key missing from `select`.** The key is not translated, so the field it was meant for is not the field written. Check the map covers every key rather than trusting the call to complain.
|
|
126
|
-
|
|
127
|
-
**A write with no `select` and your own key names.** Without a map, `data` must be keyed by raw field ids.
|
|
128
|
-
|
|
129
|
-
**Passing an options object to `delete`.** The second argument is the id itself. `delete` throws when the record cannot be deleted.
|
|
130
|
-
|
|
131
|
-
**Expecting `select` on a read-then-write round trip.** `ds.get` takes no `select`, so a record fetched by id answers with raw field ids. Load `reading-records` before pairing a read with a write.
|
|
132
|
-
|
|
133
|
-
**Reading the visitor from somewhere other than `context`.** There is nothing to import. See [current-user](../current-user/SKILL.md).
|
|
134
|
-
|
|
135
|
-
## References
|
|
136
|
-
|
|
137
|
-
- [reading-records/SKILL.md](../reading-records/SKILL.md): fetching the record a write is about to change
|
|
138
|
-
- [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor off `context` to stamp or scope a write
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: reading-records
|
|
3
|
-
description: >-
|
|
4
|
-
Fetch records from a Softr datasource inside a server function: ds.list and
|
|
5
|
-
ds.get from @softr/sdk/server, the q query builder (q.text, q.number,
|
|
6
|
-
q.boolean, q.date, q.array, q.and, q.or, q.asc, q.desc), q.select field
|
|
7
|
-
aliasing, filtering, sorting, the 20-record default and 100-record cap, and
|
|
8
|
-
nextOffset pagination. Reads only. Mutations, workflows, integrations, and
|
|
9
|
-
chart/metric aggregations (ds.chart, ds.metric) are not covered.
|
|
10
|
-
metadata:
|
|
11
|
-
type: sub-skill
|
|
12
|
-
library: softr-sdk
|
|
13
|
-
library_version: 0.0.28
|
|
14
|
-
---
|
|
15
|
-
|
|
16
|
-
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `q` and every type come from `@softr/sdk` and can be imported anywhere. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
|
|
17
|
-
|
|
18
|
-
# Reading records
|
|
19
|
-
|
|
20
|
-
`ds` reads records out of a Softr datasource, from the server only.
|
|
21
|
-
|
|
22
|
-
## Setup
|
|
23
|
-
|
|
24
|
-
```ts
|
|
25
|
-
import { createServerFn } from '@tanstack/react-start'
|
|
26
|
-
import { ds } from '@softr/sdk/server'
|
|
27
|
-
import { q } from '@softr/sdk'
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
Every `ds` call names its datasource by an opaque UUID from the table's registration on the application (Softr Studio's Data tab, or the `register_application_data_source` tool when an agent does it). It is not the table id, and it is never derived from the table's name — `'ds_posts'` or a name glued to a uuid fails at request time as not found or forbidden.
|
|
31
|
-
|
|
32
|
-
**The datasource id must be a string literal at the call site.** Do not assign it to a variable and pass the variable. Write the UUID directly as the first argument:
|
|
33
|
-
|
|
34
|
-
```ts
|
|
35
|
-
// correct
|
|
36
|
-
ds.list('8ffb4814-b028-4496-b679-7face2167ebc', { ... })
|
|
37
|
-
|
|
38
|
-
// wrong
|
|
39
|
-
const POSTS = '8ffb4814-b028-4496-b679-7face2167ebc'
|
|
40
|
-
ds.list(POSTS, { ... })
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
A record comes back as `{ id: string; fields: Record<string, unknown> }`. When you write a TypeScript type for the fields, the shape of each value depends on the Softr field type — see [references/field-types.md](./references/field-types.md) for the full mapping.
|
|
44
|
-
|
|
45
|
-
## Typing record fields
|
|
46
|
-
|
|
47
|
-
Declare a local type for the records you fetch using `SoftrRecord<Fields>`. Use the shapes from the field-types reference, not guesses from the field name. The most common mistake is treating SELECT and LINKED_RECORD fields as strings — they are objects:
|
|
48
|
-
|
|
49
|
-
```ts
|
|
50
|
-
import type { SoftrRecord } from '@softr/sdk'
|
|
51
|
-
|
|
52
|
-
type Product = SoftrRecord<{
|
|
53
|
-
name: string // SINGLE_LINE_TEXT
|
|
54
|
-
price: number | null // CURRENCY
|
|
55
|
-
category: { id: string; label: string } // SELECT — not a string
|
|
56
|
-
supplier: { id: string; label: string } // LINKED_RECORD — not a string
|
|
57
|
-
}>
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
`SoftrRecord<Fields>` is shorthand for `{ id: string; fields: Fields }` — the `Fields` type parameter constrains every value to `JsonValue`, so TypeScript rejects non-serializable types like `Date` objects.
|
|
61
|
-
|
|
62
|
-
Cast at the `ds.list` call using the `ListResult` generic so the type flows through the loader automatically:
|
|
63
|
-
|
|
64
|
-
```ts
|
|
65
|
-
import type { ListResult, SoftrRecord } from '@softr/sdk'
|
|
66
|
-
|
|
67
|
-
const fetchProducts = createServerFn().handler(() =>
|
|
68
|
-
ds.list('3c1e8a52-6f0d-4b7e-9a21-5d84c0f7b2e6', { select, limit: 100 }) as Promise<ListResult<Product>>
|
|
69
|
-
)
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
`loaderData` is then already typed as `ListResult<Product>` — no second cast needed in the component. All the shapes in [references/field-types.md](./references/field-types.md) satisfy the `JsonValue` constraint.
|
|
73
|
-
|
|
74
|
-
## Core Patterns
|
|
75
|
-
|
|
76
|
-
### List records
|
|
77
|
-
|
|
78
|
-
```ts
|
|
79
|
-
const listPublishedPosts = createServerFn().handler(async () => {
|
|
80
|
-
return await ds.list('8ffb4814-b028-4496-b679-7face2167ebc', {
|
|
81
|
-
select: q.select({
|
|
82
|
-
title: 'fZk3Qm9pLw2Xa1',
|
|
83
|
-
author: 'mB4kQ8yTn1sWc7',
|
|
84
|
-
published: 'Ru7bVx2cNq0Ed9',
|
|
85
|
-
publishedAt: 'Xp2dLh6vKz9Fa3',
|
|
86
|
-
}),
|
|
87
|
-
where: q.boolean('published').is(true),
|
|
88
|
-
orderBy: q.desc('publishedAt'),
|
|
89
|
-
limit: 50,
|
|
90
|
-
})
|
|
91
|
-
})
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
Returns `{ records, total, nextOffset }`. `ds.list(POSTS)` on its own returns the first 20 records with their raw field ids. `limit` defaults to 20 and is clamped to 100. A larger number is silently reduced, so more than a hundred records means paginating.
|
|
95
|
-
|
|
96
|
-
**Filtering or sorting requires a `select`, and it has to cover every field you name.** `where` and `orderBy` speak aliases, so without a map to translate through they throw at request time. That is why `published` is in the map above even though the table never displays it.
|
|
97
|
-
|
|
98
|
-
### Name the fields with `select`
|
|
99
|
-
|
|
100
|
-
Softr field ids are opaque strings (`fZk3Qm9pLw2Xa1`), copied from the table's schema. `q.select` maps your own names onto them, and those names are what the rest of the call speaks:
|
|
101
|
-
|
|
102
|
-
```ts
|
|
103
|
-
const select = q.select({ title: 'fZk3Qm9pLw2Xa1', views: 'Ru7bVx2cNq0Ed9' })
|
|
104
|
-
|
|
105
|
-
await ds.list('8ffb4814-b028-4496-b679-7face2167ebc', {
|
|
106
|
-
select,
|
|
107
|
-
where: q.number('views').gt(100), // alias, not Ru7bVx2cNq0Ed9
|
|
108
|
-
orderBy: q.asc('title'), // alias here too
|
|
109
|
-
})
|
|
110
|
-
// records[0].fields is { title: ..., views: ... }
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
Once `select` is present it is the whole vocabulary. The returned record carries exactly those keys, and any field you filter or sort on must appear in the map.
|
|
114
|
-
|
|
115
|
-
### Get one record
|
|
116
|
-
|
|
117
|
-
```ts
|
|
118
|
-
const post = await ds.get('8ffb4814-b028-4496-b679-7face2167ebc', { id: postId })
|
|
119
|
-
if (post === null) throw notFound()
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
A record that does not exist comes back as `null`, not an error.
|
|
123
|
-
|
|
124
|
-
The type also accepts `{ where }` instead of `{ id }`, but there is no way to pass a `select` alongside it, so the filter cannot be translated and the call throws. Fetch one record by id, or use `ds.list` with a `limit` of 1 when you need to find it by a field.
|
|
125
|
-
|
|
126
|
-
### Filter with `q`
|
|
127
|
-
|
|
128
|
-
```ts
|
|
129
|
-
where: q.and(
|
|
130
|
-
q.text('status').is('active'),
|
|
131
|
-
q.number('amount').gte(100),
|
|
132
|
-
q.or(
|
|
133
|
-
q.text('owner').is(user.id),
|
|
134
|
-
q.array('collaborators').hasAllOf([user.id]),
|
|
135
|
-
),
|
|
136
|
-
)
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
Five field types, `q.text`, `q.number`, `q.boolean`, `q.date` and `q.array`, combined with `q.and` and `q.or`. The full operator matrix is in [references/query-builder.md](./references/query-builder.md).
|
|
140
|
-
|
|
141
|
-
### Paginate
|
|
142
|
-
|
|
143
|
-
```ts
|
|
144
|
-
const first = await ds.list('8ffb4814-b028-4496-b679-7face2167ebc', { limit: 50 })
|
|
145
|
-
if (first.nextOffset !== null) {
|
|
146
|
-
const second = await ds.list('8ffb4814-b028-4496-b679-7face2167ebc', { limit: 50, offset: first.nextOffset })
|
|
147
|
-
}
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
`nextOffset` is `null` when there are no more pages. `total` is the count of all matching records, not the count returned.
|
|
151
|
-
|
|
152
|
-
### Scope the read to the visitor
|
|
153
|
-
|
|
154
|
-
```ts
|
|
155
|
-
const listMyInvoices = createServerFn().handler(async ({ context }) => {
|
|
156
|
-
if (!context.user) throw new Error('Not signed in')
|
|
157
|
-
|
|
158
|
-
return await ds.list('e4b7c2a1-9d3f-4e68-b05a-7f1c6d2e8a93', {
|
|
159
|
-
select: q.select({ owner: 'Gq5wNt8rJy3Hb1', amount: 'Vk9cXm2pSz6Ld4' }),
|
|
160
|
-
where: q.text('owner').is(context.user.id),
|
|
161
|
-
})
|
|
162
|
-
})
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
See [current-user](../current-user/SKILL.md) for how the visitor gets onto `context`.
|
|
166
|
-
|
|
167
|
-
A server function is its own endpoint, reachable whether or not the visitor ever matched the route that displays the data. A route's visibility rule decides who sees a page. The server function decides for itself who gets the records.
|
|
168
|
-
|
|
169
|
-
A read the visitor is not allowed to make throws rather than coming back empty, and it throws a plain `Error` carrying the status. There is no typed predicate to narrow it with. Handle it where you would handle any other failed fetch: an `errorComponent` on the route, or a check in the handler.
|
|
170
|
-
|
|
171
|
-
## References
|
|
172
|
-
|
|
173
|
-
- [references/field-types.md](./references/field-types.md): TypeScript shape for every Softr field type
|
|
174
|
-
- [references/query-builder.md](./references/query-builder.md): every `q` field type and operator
|
|
175
|
-
- [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor off `context` to scope a query
|
|
176
|
-
- [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): deciding who sees the page the records land on
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
# Softr field types — TypeScript shapes
|
|
2
|
-
|
|
3
|
-
When you write a TypeScript type for a record's fields, use these shapes. The field's **type** (shown in the datasource metadata or described by the user) determines the shape, not the field's name.
|
|
4
|
-
|
|
5
|
-
```ts
|
|
6
|
-
// Simple scalar types
|
|
7
|
-
SINGLE_LINE_TEXT: string
|
|
8
|
-
LONG_TEXT: string
|
|
9
|
-
EMAIL: string
|
|
10
|
-
PHONE: string
|
|
11
|
-
URL: string
|
|
12
|
-
ADDRESS: string
|
|
13
|
-
LOCATION: string
|
|
14
|
-
BUTTON: string
|
|
15
|
-
RECORD_ID: string
|
|
16
|
-
DATETIME: string // ISO timestamp string
|
|
17
|
-
DATE: string // ISO date string
|
|
18
|
-
TIME: string
|
|
19
|
-
CREATED_AT: string
|
|
20
|
-
UPDATED_AT: string
|
|
21
|
-
CHECKBOX: string | boolean
|
|
22
|
-
AUTONUMBER: number | null
|
|
23
|
-
NUMBER: number | null
|
|
24
|
-
PERCENT: number | null
|
|
25
|
-
CURRENCY: number | null
|
|
26
|
-
PROGRESS: number | null
|
|
27
|
-
DURATION: number | null
|
|
28
|
-
COUNT: number | null
|
|
29
|
-
RATING: string | number | null
|
|
30
|
-
FORMULA: string | number
|
|
31
|
-
|
|
32
|
-
// Object types — these are NOT plain strings
|
|
33
|
-
SELECT: { id: string; label: string }
|
|
34
|
-
LINKED_RECORD: { id: string; label: string }
|
|
35
|
-
|
|
36
|
-
DATE_RANGE: { from: string; to: string }
|
|
37
|
-
|
|
38
|
-
ATTACHMENT: {
|
|
39
|
-
filename: string | null | undefined
|
|
40
|
-
id: string | null | undefined
|
|
41
|
-
type: string | null | undefined
|
|
42
|
-
url: string
|
|
43
|
-
thumbnails?: Array<{ size: 'small' | 'medium' | 'large'; url: string }>
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
USER: { avatarUrl: string | null; id: string; name: string; email: string }
|
|
47
|
-
|
|
48
|
-
CREATED_BY: string | string[] | { avatarUrl: string | null; id: string; name: string; email: string }
|
|
49
|
-
UPDATED_BY: string | string[] | { avatarUrl: string | null; id: string; name: string; email: string }
|
|
50
|
-
|
|
51
|
-
// These depend on what the linked field/aggregation resolves to
|
|
52
|
-
LOOKUP: unknown
|
|
53
|
-
ROLLUP: unknown
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
## The non-obvious ones
|
|
57
|
-
|
|
58
|
-
**SELECT and LINKED_RECORD** are the most commonly mistyped. They are never a plain string — always `{ id: string; label: string }`. To display the value, use `.label`. To filter or compare, use `.id`.
|
|
59
|
-
|
|
60
|
-
```tsx
|
|
61
|
-
// Correct
|
|
62
|
-
<td>{record.fields.status?.label}</td>
|
|
63
|
-
|
|
64
|
-
// Wrong — status is not a string
|
|
65
|
-
<td>{record.fields.status}</td>
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
**CHECKBOX** can be either a string (`'true'` / `'false'`) or a boolean depending on the datasource. Type it as `string | boolean` and coerce when needed: `!!record.fields.active`.
|
|
69
|
-
|
|
70
|
-
**CREATED_BY / UPDATED_BY** can be a plain id string, an array of ids, or a full user object. Narrow before using.
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
# `q`, the query builder
|
|
2
|
-
|
|
3
|
-
Every filter, sort and field map passed to `ds` is built with `q`, imported from `@softr/sdk`. Filters and sorts built any other way are rejected at runtime.
|
|
4
|
-
|
|
5
|
-
```ts
|
|
6
|
-
import { q } from '@softr/sdk'
|
|
7
|
-
```
|
|
8
|
-
|
|
9
|
-
## Shape
|
|
10
|
-
|
|
11
|
-
```ts
|
|
12
|
-
await ds.list(datasourceId, {
|
|
13
|
-
select: q.select({ alias: '<field id>', ... }), // optional field map
|
|
14
|
-
where: <one filter, or q.and/q.or of filters>,
|
|
15
|
-
orderBy: q.asc('alias') | [q.desc('a'), q.asc('b')],
|
|
16
|
-
limit: number, // default 20, clamped down to 100
|
|
17
|
-
offset: number | null,
|
|
18
|
-
})
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
`where` takes exactly one value. For more than one condition, wrap them in `q.and(...)` or `q.or(...)`.
|
|
22
|
-
|
|
23
|
-
## Field types and operators
|
|
24
|
-
|
|
25
|
-
Pick the builder that matches the field's type. The type travels with the operator and decides how the value is compared.
|
|
26
|
-
|
|
27
|
-
### `q.text(field)`
|
|
28
|
-
|
|
29
|
-
| Call | Meaning |
|
|
30
|
-
| --- | --- |
|
|
31
|
-
| `.is(value: string)` | Exactly equal |
|
|
32
|
-
| `.isNot(value: string)` | Not equal |
|
|
33
|
-
| `.contains(value: string)` | Substring match |
|
|
34
|
-
| `.startsWith(value: string)` | Prefix match |
|
|
35
|
-
| `.endsWith(value: string)` | Suffix match |
|
|
36
|
-
| `.isOneOf(values: string[])` | Equal to any of |
|
|
37
|
-
| `.isNoneOf(values: string[])` | Equal to none of |
|
|
38
|
-
| `.hasAllOf(values: string[])` | Contains all of |
|
|
39
|
-
| `.isEmpty()` | No value |
|
|
40
|
-
| `.isNotEmpty()` | Has a value |
|
|
41
|
-
|
|
42
|
-
### `q.number(field)`
|
|
43
|
-
|
|
44
|
-
| Call | Meaning |
|
|
45
|
-
| --- | --- |
|
|
46
|
-
| `.is(value: number)` | Equal |
|
|
47
|
-
| `.isNot(value: number)` | Not equal |
|
|
48
|
-
| `.gt(value: number)` | Greater than |
|
|
49
|
-
| `.gte(value: number)` | Greater than or equal |
|
|
50
|
-
| `.lt(value: number)` | Less than |
|
|
51
|
-
| `.lte(value: number)` | Less than or equal |
|
|
52
|
-
| `.between(start: number, end: number)` | Inclusive range |
|
|
53
|
-
| `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
|
|
54
|
-
|
|
55
|
-
Takes JavaScript numbers.
|
|
56
|
-
|
|
57
|
-
### `q.boolean(field)`
|
|
58
|
-
|
|
59
|
-
| Call | Meaning |
|
|
60
|
-
| --- | --- |
|
|
61
|
-
| `.is(value: boolean)` | Equal |
|
|
62
|
-
| `.isNot(value: boolean)` | Not equal |
|
|
63
|
-
| `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
|
|
64
|
-
|
|
65
|
-
### `q.date(field)`
|
|
66
|
-
|
|
67
|
-
| Call | Meaning |
|
|
68
|
-
| --- | --- |
|
|
69
|
-
| `.is(value: string)` | On that date |
|
|
70
|
-
| `.isNot(value: string)` | Not on that date |
|
|
71
|
-
| `.gt(value: string)` | After |
|
|
72
|
-
| `.gte(value: string)` | On or after |
|
|
73
|
-
| `.lt(value: string)` | Before |
|
|
74
|
-
| `.lte(value: string)` | On or before |
|
|
75
|
-
| `.between(start: string, end: string)` | Inclusive range |
|
|
76
|
-
| `.isNotBetween(start: string, end: string)` | Outside the range |
|
|
77
|
-
| `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
|
|
78
|
-
|
|
79
|
-
Every value is an ISO date string, either `'2026-01-31'` or a full timestamp. A `Date` instance is not accepted. Call `.toISOString()` first.
|
|
80
|
-
|
|
81
|
-
### `q.array(field)`
|
|
82
|
-
|
|
83
|
-
For multi-value fields: linked records, multi-selects, collaborators.
|
|
84
|
-
|
|
85
|
-
| Call | Meaning |
|
|
86
|
-
| --- | --- |
|
|
87
|
-
| `.is(values: string[])` | Exactly this set |
|
|
88
|
-
| `.isOneOf(values: string[])` | Contains at least one of |
|
|
89
|
-
| `.isNoneOf(values: string[])` | Contains none of |
|
|
90
|
-
| `.hasAllOf(values: string[])` | Contains all of |
|
|
91
|
-
| `.isEmpty()` / `.isNotEmpty()` | Empty / non-empty |
|
|
92
|
-
|
|
93
|
-
## Combining
|
|
94
|
-
|
|
95
|
-
```ts
|
|
96
|
-
q.and(...filters) // all must match
|
|
97
|
-
q.or(...filters) // any may match
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
Both take filters or nested groups:
|
|
101
|
-
|
|
102
|
-
```ts
|
|
103
|
-
where: q.and(
|
|
104
|
-
q.text('status').is('active'),
|
|
105
|
-
q.or(
|
|
106
|
-
q.number('amount').gte(1000),
|
|
107
|
-
q.array('tags').hasAllOf(['priority']),
|
|
108
|
-
),
|
|
109
|
-
)
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
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.
|
|
113
|
-
|
|
114
|
-
## Sorting
|
|
115
|
-
|
|
116
|
-
```ts
|
|
117
|
-
orderBy: q.asc('title')
|
|
118
|
-
orderBy: [q.desc('publishedAt'), q.asc('title')]
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
Sorts are applied in array order.
|
|
122
|
-
|
|
123
|
-
## Field maps
|
|
124
|
-
|
|
125
|
-
```ts
|
|
126
|
-
const select = q.select({ title: 'fZk3Qm9pLw2Xa1', views: 'Ru7bVx2cNq0Ed9' })
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
`q.select` maps names you choose onto Softr field ids — opaque strings copied from the table's schema. Two rules follow from that:
|
|
130
|
-
|
|
131
|
-
1. The returned record carries exactly the keys in the map. A field left out is not returned, and no error is raised.
|
|
132
|
-
2. `where` and `orderBy` name aliases, and every one of them must be in the map. A name that is not throws `Could not find an alias for subject "..."` at request time, and so does a `where` or `orderBy` passed with no `select` at all.
|
|
133
|
-
|
|
134
|
-
So `select` must list every field the query touches, not just the ones being displayed.
|
|
135
|
-
|
|
136
|
-
## Errors
|
|
137
|
-
|
|
138
|
-
| Message | Cause |
|
|
139
|
-
| --- | --- |
|
|
140
|
-
| `Filters cannot be constructed manually. Use the 'q' helper.` | A `where` value built as a plain object |
|
|
141
|
-
| `orderBy conditions cannot be constructed manually. Use the 'q' helper.` | An `orderBy` value not from `q.asc` / `q.desc` |
|
|
142
|
-
| `Cannot nest filter groups more than three levels deep.` | A group nested one level too far |
|
|
143
|
-
| `Could not find an alias for subject "..."` | A sort field missing from `select`, or an `orderBy` with no `select` at all |
|
|
144
|
-
| `Could not find an alias for subject "undefined"` | The same thing on a filter, which does not report the field name |
|