@softr/sdk 0.0.26-alpha-0 → 0.0.26
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 +618 -2
- package/dist/codegen/softr-env.js +2 -1
- package/dist/package.js +1 -1
- package/package.json +10 -32
- package/skills/softr-sdk/SKILL.md +11 -11
- package/skills/softr-sdk/auth/SKILL.md +1 -1
- package/skills/softr-sdk/current-user/SKILL.md +1 -1
- package/skills/softr-sdk/data-mutations/SKILL.md +56 -76
- package/skills/softr-sdk/integrations/SKILL.md +140 -0
- package/skills/softr-sdk/reading-records/SKILL.md +93 -77
- package/skills/softr-sdk/reading-records/references/field-types.md +48 -41
- package/skills/softr-sdk/reading-records/references/query-builder.md +70 -66
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +1 -1
- package/dist/codegen/fetch-metadata.js +0 -46
- package/skills/softr-sdk/charts-and-metrics/SKILL.md +0 -178
- package/skills/softr-sdk/charts-and-metrics/references/aggregations.md +0 -76
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: reading-records
|
|
3
3
|
description: >-
|
|
4
|
-
Fetch records from a Softr datasource inside a server function:
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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, metrics
|
|
9
|
+
and chart data are not covered.
|
|
9
10
|
metadata:
|
|
10
11
|
type: sub-skill
|
|
11
12
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
13
|
+
library_version: 0.0.26
|
|
13
14
|
---
|
|
14
15
|
|
|
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.
|
|
@@ -22,37 +23,53 @@ metadata:
|
|
|
22
23
|
|
|
23
24
|
```ts
|
|
24
25
|
import { createServerFn } from '@tanstack/react-start'
|
|
25
|
-
import { defineAction } from '@softr/sdk'
|
|
26
26
|
import { ds } from '@softr/sdk/server'
|
|
27
|
+
import { q } from '@softr/sdk'
|
|
27
28
|
```
|
|
28
29
|
|
|
29
|
-
`ds`
|
|
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.
|
|
30
31
|
|
|
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:
|
|
32
33
|
|
|
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:
|
|
34
48
|
|
|
35
49
|
```ts
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
views: 'mB4kQ8yTn1sWc7', // NUMBER
|
|
45
|
-
},
|
|
46
|
-
permission: { userGroup: 'ALL_USERS' },
|
|
47
|
-
})
|
|
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
|
+
}>
|
|
48
58
|
```
|
|
49
59
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
- Declare at module scope, every property a literal. The build reads the declaration without executing it, so a computed id, a shared constant or a spread does not work.
|
|
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:
|
|
54
63
|
|
|
55
|
-
|
|
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.
|
|
56
73
|
|
|
57
74
|
## Core Patterns
|
|
58
75
|
|
|
@@ -60,62 +77,73 @@ An action fixes the table and the columns, not the rows: filters, sorting and pa
|
|
|
60
77
|
|
|
61
78
|
```ts
|
|
62
79
|
const listPublishedPosts = createServerFn().handler(async () => {
|
|
63
|
-
return await ds
|
|
64
|
-
.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
})
|
|
68
91
|
})
|
|
69
92
|
```
|
|
70
93
|
|
|
71
|
-
Returns `{ records, total, nextOffset }`.
|
|
72
|
-
|
|
73
|
-
### Filter with the `where` callback
|
|
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.
|
|
74
95
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
```ts
|
|
78
|
-
await ds.list(publishedPosts).where((q) =>
|
|
79
|
-
q.$and(
|
|
80
|
-
q.published.is(true),
|
|
81
|
-
q.views.gte(100),
|
|
82
|
-
q.$or(q.title.contains('cafe'), q.title.startsWith('The')),
|
|
83
|
-
),
|
|
84
|
-
)
|
|
85
|
-
```
|
|
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.
|
|
86
97
|
|
|
87
|
-
|
|
98
|
+
### Name the fields with `select`
|
|
88
99
|
|
|
89
|
-
|
|
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:
|
|
90
101
|
|
|
91
102
|
```ts
|
|
92
|
-
.
|
|
93
|
-
|
|
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: ... }
|
|
94
111
|
```
|
|
95
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
|
+
|
|
96
115
|
### Get one record
|
|
97
116
|
|
|
98
117
|
```ts
|
|
99
|
-
const post = await ds.get(
|
|
118
|
+
const post = await ds.get('8ffb4814-b028-4496-b679-7face2167ebc', { id: postId })
|
|
100
119
|
if (post === null) throw notFound()
|
|
101
120
|
```
|
|
102
121
|
|
|
103
|
-
A record that does not exist comes back as `null`, not an error.
|
|
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`
|
|
104
127
|
|
|
105
128
|
```ts
|
|
106
|
-
|
|
107
|
-
.
|
|
108
|
-
.
|
|
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
|
+
)
|
|
109
137
|
```
|
|
110
138
|
|
|
111
|
-
|
|
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).
|
|
112
140
|
|
|
113
141
|
### Paginate
|
|
114
142
|
|
|
115
143
|
```ts
|
|
116
|
-
const first = await ds.list(
|
|
144
|
+
const first = await ds.list('8ffb4814-b028-4496-b679-7face2167ebc', { limit: 50 })
|
|
117
145
|
if (first.nextOffset !== null) {
|
|
118
|
-
const second = await ds.list(
|
|
146
|
+
const second = await ds.list('8ffb4814-b028-4496-b679-7face2167ebc', { limit: 50, offset: first.nextOffset })
|
|
119
147
|
}
|
|
120
148
|
```
|
|
121
149
|
|
|
@@ -124,18 +152,13 @@ if (first.nextOffset !== null) {
|
|
|
124
152
|
### Scope the read to the visitor
|
|
125
153
|
|
|
126
154
|
```ts
|
|
127
|
-
const myInvoices = defineAction({
|
|
128
|
-
id: 'myInvoices',
|
|
129
|
-
datasource: 'e4b7c2a1-9d3f-4e68-b05a-7f1c6d2e8a93',
|
|
130
|
-
operation: 'read',
|
|
131
|
-
fields: { owner: 'Gq5wNt8rJy3Hb1', amount: 'Vk9cXm2pSz6Ld4' },
|
|
132
|
-
permission: { userGroup: 'LOGGED_IN_USERS' },
|
|
133
|
-
})
|
|
134
|
-
|
|
135
155
|
const listMyInvoices = createServerFn().handler(async ({ context }) => {
|
|
136
156
|
if (!context.user) throw new Error('Not signed in')
|
|
137
|
-
|
|
138
|
-
return await ds.list(
|
|
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
|
+
})
|
|
139
162
|
})
|
|
140
163
|
```
|
|
141
164
|
|
|
@@ -145,16 +168,9 @@ A server function is its own endpoint, reachable whether or not the visitor ever
|
|
|
145
168
|
|
|
146
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.
|
|
147
170
|
|
|
148
|
-
## Typing
|
|
149
|
-
|
|
150
|
-
Records, filters and sorts are typed from `softr-env.d.ts`, the generated file at the app root describing every datasource registered on the application. Regenerate it with `npx @softr/sdk generate` after the data changes. With it in place, field ids autocomplete inside `fields`, each `where` handle offers only its field's operators, and `records[0].fields` carries real value types with no cast anywhere. Without it everything still runs, but field values type as `unknown` and every handle offers every operator.
|
|
151
|
-
|
|
152
|
-
What each Softr field type reads back as is in [references/field-types.md](./references/field-types.md). The two most commonly mistyped: SELECT and LINKED_RECORD values are `{ id, label }` objects, never plain strings. Render the `label`; filter by the `id`.
|
|
153
|
-
|
|
154
171
|
## References
|
|
155
172
|
|
|
156
|
-
- [references/
|
|
157
|
-
- [references/
|
|
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
|
|
158
175
|
- [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor off `context` to scope a query
|
|
159
176
|
- [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): deciding who sees the page the records land on
|
|
160
|
-
- [charts-and-metrics/SKILL.md](../charts-and-metrics/SKILL.md): aggregating records instead of listing them
|
|
@@ -1,63 +1,70 @@
|
|
|
1
|
-
# Softr field types
|
|
1
|
+
# Softr field types — TypeScript shapes
|
|
2
2
|
|
|
3
|
-
|
|
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`.
|
|
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.
|
|
10
4
|
|
|
11
5
|
```ts
|
|
12
|
-
//
|
|
13
|
-
SINGLE_LINE_TEXT
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
|
|
29
36
|
DATE_RANGE: { from: string; to: string }
|
|
30
|
-
|
|
37
|
+
|
|
31
38
|
ATTACHMENT: {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
type: string | null
|
|
39
|
+
filename: string | null | undefined
|
|
40
|
+
id: string | null | undefined
|
|
41
|
+
type: string | null | undefined
|
|
35
42
|
url: string
|
|
36
|
-
expiresAt: string | null
|
|
37
|
-
size?: number
|
|
38
43
|
thumbnails?: Array<{ size: 'small' | 'medium' | 'large'; url: string }>
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 }
|
|
44
50
|
|
|
45
|
-
|
|
51
|
+
// These depend on what the linked field/aggregation resolves to
|
|
52
|
+
LOOKUP: unknown
|
|
53
|
+
ROLLUP: unknown
|
|
54
|
+
```
|
|
46
55
|
|
|
47
56
|
## The non-obvious ones
|
|
48
57
|
|
|
49
|
-
**SELECT and LINKED_RECORD** are the most commonly mistyped. They are never a plain string
|
|
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`.
|
|
50
59
|
|
|
51
60
|
```tsx
|
|
52
61
|
// Correct
|
|
53
62
|
<td>{record.fields.status?.label}</td>
|
|
54
63
|
|
|
55
|
-
// Wrong
|
|
64
|
+
// Wrong — status is not a string
|
|
56
65
|
<td>{record.fields.status}</td>
|
|
57
66
|
```
|
|
58
67
|
|
|
59
|
-
**
|
|
60
|
-
|
|
61
|
-
**DATETIME at date precision** is generated as a `DATE` field and holds `'2026-03-14'`, not a timestamp.
|
|
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`.
|
|
62
69
|
|
|
63
|
-
**
|
|
70
|
+
**CREATED_BY / UPDATED_BY** can be a plain id string, an array of ids, or a full user object. Narrow before using.
|
|
@@ -1,27 +1,30 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `q`, the query builder
|
|
2
2
|
|
|
3
|
-
Every filter
|
|
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
|
+
```
|
|
4
8
|
|
|
5
9
|
## Shape
|
|
6
10
|
|
|
7
11
|
```ts
|
|
8
|
-
await ds
|
|
9
|
-
.
|
|
10
|
-
|
|
11
|
-
.
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
})
|
|
14
19
|
```
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
## Operators by field type
|
|
21
|
+
`where` takes exactly one value. For more than one condition, wrap them in `q.and(...)` or `q.or(...)`.
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
## Field types and operators
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
Pick the builder that matches the field's type. The type travels with the operator and decides how the value is compared.
|
|
23
26
|
|
|
24
|
-
|
|
27
|
+
### `q.text(field)`
|
|
25
28
|
|
|
26
29
|
| Call | Meaning |
|
|
27
30
|
| --- | --- |
|
|
@@ -32,56 +35,52 @@ The handle for each field carries the operators its declared type supports, take
|
|
|
32
35
|
| `.endsWith(value: string)` | Suffix match |
|
|
33
36
|
| `.isOneOf(values: string[])` | Equal to any of |
|
|
34
37
|
| `.isNoneOf(values: string[])` | Equal to none of |
|
|
35
|
-
| `.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
| `.hasAllOf(values: string[])` | Contains all of |
|
|
39
|
+
| `.isEmpty()` | No value |
|
|
40
|
+
| `.isNotEmpty()` | Has a value |
|
|
38
41
|
|
|
39
|
-
|
|
42
|
+
### `q.number(field)`
|
|
40
43
|
|
|
41
44
|
| Call | Meaning |
|
|
42
45
|
| --- | --- |
|
|
43
|
-
| `.is(value: number)`
|
|
44
|
-
| `.
|
|
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 |
|
|
45
52
|
| `.between(start: number, end: number)` | Inclusive range |
|
|
46
53
|
| `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
|
|
47
54
|
|
|
48
|
-
|
|
55
|
+
Takes JavaScript numbers.
|
|
49
56
|
|
|
50
|
-
|
|
51
|
-
| --- | --- |
|
|
52
|
-
| `.is(value: boolean)` / `.isNot(value: boolean)` | Equal / not equal |
|
|
53
|
-
| `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
|
|
54
|
-
|
|
55
|
-
### Date fields
|
|
56
|
-
|
|
57
|
-
`DATE`, `DATETIME`, `CREATED_AT`, `UPDATED_AT`.
|
|
57
|
+
### `q.boolean(field)`
|
|
58
58
|
|
|
59
59
|
| Call | Meaning |
|
|
60
60
|
| --- | --- |
|
|
61
|
-
| `.is(value:
|
|
62
|
-
| `.
|
|
63
|
-
| `.lt(value)` / `.lte(value)` | Before / on or before |
|
|
64
|
-
| `.between(start, end)` | Inclusive range |
|
|
65
|
-
| `.isNotBetween(start, end)` | Outside the range |
|
|
61
|
+
| `.is(value: boolean)` | Equal |
|
|
62
|
+
| `.isNot(value: boolean)` | Not equal |
|
|
66
63
|
| `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
|
|
67
64
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
### Choice fields
|
|
71
|
-
|
|
72
|
-
`SELECT`, `LINKED_RECORD`, `CREATED_BY`, `UPDATED_BY`.
|
|
65
|
+
### `q.date(field)`
|
|
73
66
|
|
|
74
67
|
| Call | Meaning |
|
|
75
68
|
| --- | --- |
|
|
76
|
-
| `.is(value
|
|
77
|
-
| `.
|
|
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 |
|
|
78
77
|
| `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
|
|
79
78
|
|
|
80
|
-
|
|
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.
|
|
81
80
|
|
|
82
|
-
###
|
|
81
|
+
### `q.array(field)`
|
|
83
82
|
|
|
84
|
-
|
|
83
|
+
For multi-value fields: linked records, multi-selects, collaborators.
|
|
85
84
|
|
|
86
85
|
| Call | Meaning |
|
|
87
86
|
| --- | --- |
|
|
@@ -91,30 +90,21 @@ Any generated type with a `[]` suffix: multi-selects, multi-entry linked records
|
|
|
91
90
|
| `.hasAllOf(values: string[])` | Contains all of |
|
|
92
91
|
| `.isEmpty()` / `.isNotEmpty()` | Empty / non-empty |
|
|
93
92
|
|
|
94
|
-
Values are the members' ids, as with choice fields.
|
|
95
|
-
|
|
96
|
-
### User fields
|
|
97
|
-
|
|
98
|
-
`USER` offers `.is`, `.isNot`, `.contains`, `.isEmpty` and `.isNotEmpty`.
|
|
99
|
-
|
|
100
|
-
### Attachment and date range fields
|
|
101
|
-
|
|
102
|
-
`ATTACHMENT` and `DATE_RANGE` have no single value to compare, so they offer only `.isEmpty()` and `.isNotEmpty()`.
|
|
103
|
-
|
|
104
93
|
## Combining
|
|
105
94
|
|
|
106
95
|
```ts
|
|
107
|
-
q
|
|
108
|
-
q
|
|
96
|
+
q.and(...filters) // all must match
|
|
97
|
+
q.or(...filters) // any may match
|
|
109
98
|
```
|
|
110
99
|
|
|
111
100
|
Both take filters or nested groups:
|
|
112
101
|
|
|
113
102
|
```ts
|
|
114
|
-
.
|
|
115
|
-
q
|
|
116
|
-
|
|
117
|
-
q
|
|
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']),
|
|
118
108
|
),
|
|
119
109
|
)
|
|
120
110
|
```
|
|
@@ -124,17 +114,31 @@ Nesting goes two levels, a group inside a group. A third level throws `Cannot ne
|
|
|
124
114
|
## Sorting
|
|
125
115
|
|
|
126
116
|
```ts
|
|
127
|
-
|
|
128
|
-
|
|
117
|
+
orderBy: q.asc('title')
|
|
118
|
+
orderBy: [q.desc('publishedAt'), q.asc('title')]
|
|
129
119
|
```
|
|
130
120
|
|
|
131
|
-
|
|
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.
|
|
132
135
|
|
|
133
136
|
## Errors
|
|
134
137
|
|
|
135
138
|
| Message | Cause |
|
|
136
139
|
| --- | --- |
|
|
137
|
-
| `
|
|
138
|
-
| `
|
|
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` |
|
|
139
142
|
| `Cannot nest filter groups more than three levels deep.` | A group nested one level too far |
|
|
140
|
-
|
|
|
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 |
|
|
@@ -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.26
|
|
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.
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { panic } from "../assertions.js";
|
|
2
|
-
|
|
3
|
-
//#region src/codegen/fetch-metadata.ts
|
|
4
|
-
function metadataSourceFromEnv(env = process.env) {
|
|
5
|
-
const missing = [
|
|
6
|
-
"SOFTR_STUDIO_API_URL",
|
|
7
|
-
"SOFTR_DATASOURCE_API_URL",
|
|
8
|
-
"SOFTR_APPLICATION_ID",
|
|
9
|
-
"SOFTR_STUDIO_TOKEN"
|
|
10
|
-
].filter((name) => !env[name]?.trim());
|
|
11
|
-
if (missing.length > 0) panic(`Cannot read the app's schema: ${missing.join(", ")} is not set.`);
|
|
12
|
-
return {
|
|
13
|
-
studioApiUrl: withoutTrailingSlash(env.SOFTR_STUDIO_API_URL),
|
|
14
|
-
datasourceApiUrl: withoutTrailingSlash(env.SOFTR_DATASOURCE_API_URL),
|
|
15
|
-
applicationId: env.SOFTR_APPLICATION_ID,
|
|
16
|
-
token: env.SOFTR_STUDIO_TOKEN
|
|
17
|
-
};
|
|
18
|
-
}
|
|
19
|
-
async function fetchSoftrEnvInput(source) {
|
|
20
|
-
const { studioApiUrl, datasourceApiUrl, applicationId, token } = source;
|
|
21
|
-
const registered = await apiFetch(`${studioApiUrl}/v1/applications/${applicationId}/datasources`, token);
|
|
22
|
-
return {
|
|
23
|
-
datasources: await Promise.all(registered.map(async ({ name, datasource }) => {
|
|
24
|
-
return {
|
|
25
|
-
...await apiFetch(`${datasourceApiUrl}/v1/datasource/vibe/applications/${applicationId}/datasources/${datasource.id}/metadata`, token),
|
|
26
|
-
id: datasource.id,
|
|
27
|
-
name
|
|
28
|
-
};
|
|
29
|
-
})),
|
|
30
|
-
userGroupIds: null
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
async function apiFetch(url, token) {
|
|
34
|
-
const response = await fetch(url, { headers: {
|
|
35
|
-
accept: "application/json",
|
|
36
|
-
Authorization: `Bearer ${token}`
|
|
37
|
-
} }).catch(() => panic(`Could not reach ${url}`));
|
|
38
|
-
if (!response.ok) panic(`${url} answered ${response.status}`);
|
|
39
|
-
return await response.json();
|
|
40
|
-
}
|
|
41
|
-
function withoutTrailingSlash(url) {
|
|
42
|
-
return url.replace(/\/+$/, "");
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
//#endregion
|
|
46
|
-
export { fetchSoftrEnvInput, metadataSourceFromEnv };
|