@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.
@@ -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: declaring a
5
- read with defineAction, ds.list and ds.get from @softr/sdk/server, the where
6
- and orderBy callbacks, field aliases, the 20-record default and 100-record
7
- cap, and nextOffset pagination. Reads only. Mutations, workflows,
8
- integrations, charts and metrics are not covered.
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.25
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` is server-only. `defineAction` only declares data operations, so it ships on the root entrypoint and the declaration can sit anywhere.
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
- ## Declare the read
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
- Every `ds` call takes an action: which datasource, which fields, who may run it.
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
- const publishedPosts = defineAction({
37
- id: 'publishedPosts',
38
- datasource: '8ffb4814-b028-4496-b679-7face2167ebc',
39
- operation: 'read',
40
- fields: {
41
- title: 'fZk3Qm9pLw2Xa1', // SINGLE_LINE_TEXT
42
- published: 'Ru7bVx2cNq0Ed9', // CHECKBOX
43
- publishedAt: 'Xp2dLh6vKz9Fa3',// DATETIME
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
- - `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. `'ds_posts'` or a name glued to a uuid fails at request time as not found or forbidden.
51
- - The keys of `fields` are names you choose. The values are field ids, opaque strings copied from `softr-env.d.ts` (see [Typing](#typing)) or the table's schema. Everything downstream speaks your names: the returned records, `where`, `orderBy`.
52
- - `permission` declares who may run the action.
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
- An action 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.
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
- .list(publishedPosts)
65
- .where((q) => q.published.is(true))
66
- .orderBy((q) => q.publishedAt.desc)
67
- .limit(50)
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 }`. Each record is `{ id, fields }`, with `fields` keyed by the action's own names: every declared field is present, `null` when empty, and a field the action does not declare is not returned. `ds.list(publishedPosts)` with no calls chained returns the first 20 records. `limit` is clamped to 100; a larger number is silently reduced, so more than a hundred records means paginating.
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
- The callback is handed one handle per declared field, carrying only the operators that field's type supports, plus `$and` and `$or`:
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
- Calling `.where` more than once ands the filters together, which is how a fixed scope and a user-supplied filter combine. Only the action's own fields can be filtered or sorted on; a field you need in a `where` must be in the action's `fields`, even when the page never displays it. The full operator matrix is in [references/query-builder.md](./references/query-builder.md).
98
+ ### Name the fields with `select`
88
99
 
89
- ### Sort
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
- .orderBy((q) => q.publishedAt.desc)
93
- .orderBy((q) => [q.views.desc, q.title.asc]) // array for tie-breakers
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(publishedPosts, { id: postId })
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. A chained `.where` narrows which record the id may resolve to. A record that does not match reads as `null`, the same as one that does not exist:
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
- const post = await ds
107
- .get(publishedPosts, { id: postId })
108
- .where((q) => q.published.is(true))
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
- 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.
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(publishedPosts).limit(50)
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(publishedPosts).limit(50).offset(first.nextOffset)
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
- const userId = context.user.id
138
- return await ds.list(myInvoices).where((q) => q.owner.is(userId))
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/query-builder.md](./references/query-builder.md): every filter operator, by field type
157
- - [references/field-types.md](./references/field-types.md): the value shape every Softr field type reads back as
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 and what their values look like
1
+ # Softr field types — TypeScript shapes
2
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`.
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
- // 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 } // generated with literal id and label unions
28
- LINKED_RECORD: { id: string; label: string | null } // label is the linked record's primary field
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
- USER: { id: string; name: string; email: string; avatarUrl: string | null }
37
+
31
38
  ATTACHMENT: {
32
- id: string | null
33
- filename: string | null
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
- // Computed fields read as whatever they return
42
- FORMULA, LOOKUP, ROLLUP: the returned field's shape, resolved in softr-env.d.ts
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
- 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.
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, 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.
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: status is not a string
64
+ // Wrong — status is not a string
56
65
  <td>{record.fields.status}</td>
57
66
  ```
58
67
 
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.
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
- **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.
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
- # Filters and sorting
1
+ # `q`, the query builder
2
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.
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
- .list(action)
10
- .where((q) => q.status.is('active')) // one filter, or a $and/$or of filters
11
- .orderBy((q) => q.title.asc) // or an array of handles
12
- .limit(20) // default 20, clamped down to 100
13
- .offset(null) // a previous result's nextOffset
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
- 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.
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
- The handle for each field carries the operators its declared type supports, taken from the generated `softr-env.d.ts`. A field with no generated type gets every operator.
23
+ ## Field types and operators
21
24
 
22
- ### Text fields
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
- `SINGLE_LINE_TEXT`, `LONG_TEXT`, `URL`, `EMAIL`, `PHONE`, `ADDRESS`, `LOCATION`, `TIME`, `BUTTON`, `RECORD_ID`.
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
- | `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
36
-
37
- ### Number fields
38
+ | `.hasAllOf(values: string[])` | Contains all of |
39
+ | `.isEmpty()` | No value |
40
+ | `.isNotEmpty()` | Has a value |
38
41
 
39
- `NUMBER`, `PERCENT`, `CURRENCY`, `PROGRESS`, `RATING`, `DURATION`, `COUNT`, `AUTONUMBER`.
42
+ ### `q.number(field)`
40
43
 
41
44
  | Call | Meaning |
42
45
  | --- | --- |
43
- | `.is(value: number)` / `.isNot(value: number)` | Equal / not equal |
44
- | `.gt(value)` / `.gte(value)` / `.lt(value)` / `.lte(value)` | Comparisons |
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
- ### Checkbox fields
55
+ Takes JavaScript numbers.
49
56
 
50
- | Call | Meaning |
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: string)` / `.isNot(value: string)` | On / not on that date |
62
- | `.gt(value)` / `.gte(value)` | After / on or after |
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
- Every value is an ISO date string, either `'2026-01-31'` or a full timestamp. A `Date` instance is not accepted. Call `.toISOString()` first.
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)` / `.isNot(value)` | Equal / not equal |
77
- | `.isOneOf(values)` / `.isNoneOf(values)` | Any of / none of |
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
- 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.
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
- ### Multi-value fields
81
+ ### `q.array(field)`
83
82
 
84
- Any generated type with a `[]` suffix: multi-selects, multi-entry linked records.
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.$and(...filters) // all must match
108
- q.$or(...filters) // any may match
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
- .where((q) =>
115
- q.$and(
116
- q.status.is('active'),
117
- q.$or(q.amount.gte(1000), q.title.contains('priority')),
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
- .orderBy((q) => q.title.asc)
128
- .orderBy((q) => [q.publishedAt.desc, q.title.asc])
117
+ orderBy: q.asc('title')
118
+ orderBy: [q.desc('publishedAt'), q.asc('title')]
129
119
  ```
130
120
 
131
- Each field carries `.asc` and `.desc` handles and nothing else. An array applies sorts in order. Repeated `.orderBy` calls append.
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
- | `a filter must come from the callback a read hands you` | A `where` value built as a plain object |
138
- | `a sort must come from the callback a read hands you` | An `orderBy` value not from the callback's `.asc` / `.desc` handles |
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
- | A `TypeError` reading an operator off `undefined` | Filtering or sorting on a field the action does not declare |
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.25
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 };