@softr/sdk 0.0.25 → 0.0.26-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.
Files changed (41) hide show
  1. package/dist/action.d.ts +195 -0
  2. package/dist/action.js +21 -0
  3. package/dist/auth/isomorphic.d.ts +1 -2
  4. package/dist/auth/permissions.d.ts +7 -2
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.js +23 -0
  7. package/dist/codegen/datasource-types.js +471 -0
  8. package/dist/codegen/fetch-metadata.js +46 -0
  9. package/dist/codegen/softr-env.js +102 -0
  10. package/dist/codegen/user-groups.js +11 -0
  11. package/dist/core/integration.js +1 -1
  12. package/dist/datasource/chart.d.ts +196 -0
  13. package/dist/datasource/chart.js +289 -0
  14. package/dist/datasource/datasource.d.ts +16 -0
  15. package/dist/datasource/datasource.js +17 -0
  16. package/dist/datasource/fields.d.ts +122 -0
  17. package/dist/datasource/fields.js +82 -0
  18. package/dist/datasource/filter.d.ts +30 -0
  19. package/dist/datasource/filter.js +146 -0
  20. package/dist/datasource/record.d.ts +138 -0
  21. package/dist/datasource/record.js +258 -0
  22. package/dist/index.d.ts +3 -5
  23. package/dist/index.js +2 -2
  24. package/dist/package.js +5 -0
  25. package/dist/routes/visibility.d.ts +1 -1
  26. package/dist/server.d.ts +1 -1
  27. package/dist/server.js +1 -1
  28. package/package.json +31 -8
  29. package/skills/softr-sdk/SKILL.md +9 -5
  30. package/skills/softr-sdk/charts-and-metrics/SKILL.md +178 -0
  31. package/skills/softr-sdk/charts-and-metrics/references/aggregations.md +76 -0
  32. package/skills/softr-sdk/data-mutations/SKILL.md +75 -55
  33. package/skills/softr-sdk/reading-records/SKILL.md +76 -92
  34. package/skills/softr-sdk/reading-records/references/field-types.md +41 -48
  35. package/skills/softr-sdk/reading-records/references/query-builder.md +66 -70
  36. package/dist/core/datasource.d.ts +0 -31
  37. package/dist/core/datasource.js +0 -226
  38. package/dist/core/query.d.ts +0 -354
  39. package/dist/core/query.js +0 -528
  40. package/dist/core/utils.js +0 -14
  41. package/dist/types.d.ts +0 -128
@@ -1,12 +1,11 @@
1
1
  ---
2
2
  name: reading-records
3
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, metrics
9
- and chart data are not covered.
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.
10
9
  metadata:
11
10
  type: sub-skill
12
11
  library: softr-sdk
@@ -23,53 +22,37 @@ metadata:
23
22
 
24
23
  ```ts
25
24
  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'
28
27
  ```
29
28
 
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.
29
+ `ds` is server-only. `defineAction` only declares data operations, so it ships on the root entrypoint and the declaration can sit anywhere.
31
30
 
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:
31
+ ## Declare the read
33
32
 
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:
33
+ Every `ds` call takes an action: which datasource, which fields, who may run it.
48
34
 
49
35
  ```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
- }>
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
+ })
58
48
  ```
59
49
 
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:
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.
63
54
 
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.
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.
73
56
 
74
57
  ## Core Patterns
75
58
 
@@ -77,73 +60,62 @@ const fetchProducts = createServerFn().handler(() =>
77
60
 
78
61
  ```ts
79
62
  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
- })
63
+ return await ds
64
+ .list(publishedPosts)
65
+ .where((q) => q.published.is(true))
66
+ .orderBy((q) => q.publishedAt.desc)
67
+ .limit(50)
91
68
  })
92
69
  ```
93
70
 
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.
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.
97
72
 
98
- ### Name the fields with `select`
73
+ ### Filter with the `where` callback
99
74
 
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:
75
+ The callback is handed one handle per declared field, carrying only the operators that field's type supports, plus `$and` and `$or`:
101
76
 
102
77
  ```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: ... }
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
+ )
111
85
  ```
112
86
 
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.
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).
114
88
 
115
- ### Get one record
89
+ ### Sort
116
90
 
117
91
  ```ts
118
- const post = await ds.get('8ffb4814-b028-4496-b679-7face2167ebc', { id: postId })
119
- if (post === null) throw notFound()
92
+ .orderBy((q) => q.publishedAt.desc)
93
+ .orderBy((q) => [q.views.desc, q.title.asc]) // array for tie-breakers
120
94
  ```
121
95
 
122
- A record that does not exist comes back as `null`, not an error.
96
+ ### Get one record
123
97
 
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.
98
+ ```ts
99
+ const post = await ds.get(publishedPosts, { id: postId })
100
+ if (post === null) throw notFound()
101
+ ```
125
102
 
126
- ### Filter with `q`
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:
127
104
 
128
105
  ```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
- )
106
+ const post = await ds
107
+ .get(publishedPosts, { id: postId })
108
+ .where((q) => q.published.is(true))
137
109
  ```
138
110
 
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).
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.
140
112
 
141
113
  ### Paginate
142
114
 
143
115
  ```ts
144
- const first = await ds.list('8ffb4814-b028-4496-b679-7face2167ebc', { limit: 50 })
116
+ const first = await ds.list(publishedPosts).limit(50)
145
117
  if (first.nextOffset !== null) {
146
- const second = await ds.list('8ffb4814-b028-4496-b679-7face2167ebc', { limit: 50, offset: first.nextOffset })
118
+ const second = await ds.list(publishedPosts).limit(50).offset(first.nextOffset)
147
119
  }
148
120
  ```
149
121
 
@@ -152,13 +124,18 @@ if (first.nextOffset !== null) {
152
124
  ### Scope the read to the visitor
153
125
 
154
126
  ```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
+
155
135
  const listMyInvoices = createServerFn().handler(async ({ context }) => {
156
136
  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
- })
137
+ const userId = context.user.id
138
+ return await ds.list(myInvoices).where((q) => q.owner.is(userId))
162
139
  })
163
140
  ```
164
141
 
@@ -168,9 +145,16 @@ A server function is its own endpoint, reachable whether or not the visitor ever
168
145
 
169
146
  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
147
 
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
+
171
154
  ## References
172
155
 
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
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
175
158
  - [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor off `context` to scope a query
176
159
  - [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,70 +1,63 @@
1
- # Softr field types — TypeScript shapes
1
+ # Softr field types and what their values look like
2
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.
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
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 }
5
+ Three rules cover nullability:
35
6
 
36
- DATE_RANGE: { from: string; to: string }
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`.
37
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 } // 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 }
38
31
  ATTACHMENT: {
39
- filename: string | null | undefined
40
- id: string | null | undefined
41
- type: string | null | undefined
32
+ id: string | null
33
+ filename: string | null
34
+ type: string | null
42
35
  url: string
36
+ expiresAt: string | null
37
+ size?: number
43
38
  thumbnails?: Array<{ size: 'small' | 'medium' | 'large'; url: string }>
44
39
  }
45
40
 
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
41
+ // Computed fields read as whatever they return
42
+ FORMULA, LOOKUP, ROLLUP: the returned field's shape, resolved in softr-env.d.ts
54
43
  ```
55
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
+
56
47
  ## The non-obvious ones
57
48
 
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`.
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.
59
50
 
60
51
  ```tsx
61
52
  // Correct
62
53
  <td>{record.fields.status?.label}</td>
63
54
 
64
- // Wrong — status is not a string
55
+ // Wrong: status is not a string
65
56
  <td>{record.fields.status}</td>
66
57
  ```
67
58
 
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`.
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.
69
62
 
70
- **CREATED_BY / UPDATED_BY** can be a plain id string, an array of ids, or a full user object. Narrow before using.
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.
@@ -1,30 +1,27 @@
1
- # `q`, the query builder
1
+ # Filters and sorting
2
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
- ```
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.
8
4
 
9
5
  ## Shape
10
6
 
11
7
  ```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
- })
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
19
14
  ```
20
15
 
21
- `where` takes exactly one value. For more than one condition, wrap them in `q.and(...)` or `q.or(...)`.
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
22
19
 
23
- ## Field types and operators
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.
24
21
 
25
- Pick the builder that matches the field's type. The type travels with the operator and decides how the value is compared.
22
+ ### Text fields
26
23
 
27
- ### `q.text(field)`
24
+ `SINGLE_LINE_TEXT`, `LONG_TEXT`, `URL`, `EMAIL`, `PHONE`, `ADDRESS`, `LOCATION`, `TIME`, `BUTTON`, `RECORD_ID`.
28
25
 
29
26
  | Call | Meaning |
30
27
  | --- | --- |
@@ -35,52 +32,56 @@ Pick the builder that matches the field's type. The type travels with the operat
35
32
  | `.endsWith(value: string)` | Suffix match |
36
33
  | `.isOneOf(values: string[])` | Equal to any of |
37
34
  | `.isNoneOf(values: string[])` | Equal to none of |
38
- | `.hasAllOf(values: string[])` | Contains all of |
39
- | `.isEmpty()` | No value |
40
- | `.isNotEmpty()` | Has a value |
35
+ | `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
36
+
37
+ ### Number fields
41
38
 
42
- ### `q.number(field)`
39
+ `NUMBER`, `PERCENT`, `CURRENCY`, `PROGRESS`, `RATING`, `DURATION`, `COUNT`, `AUTONUMBER`.
43
40
 
44
41
  | Call | Meaning |
45
42
  | --- | --- |
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 |
43
+ | `.is(value: number)` / `.isNot(value: number)` | Equal / not equal |
44
+ | `.gt(value)` / `.gte(value)` / `.lt(value)` / `.lte(value)` | Comparisons |
52
45
  | `.between(start: number, end: number)` | Inclusive range |
53
46
  | `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
54
47
 
55
- Takes JavaScript numbers.
56
-
57
- ### `q.boolean(field)`
48
+ ### Checkbox fields
58
49
 
59
50
  | Call | Meaning |
60
51
  | --- | --- |
61
- | `.is(value: boolean)` | Equal |
62
- | `.isNot(value: boolean)` | Not equal |
52
+ | `.is(value: boolean)` / `.isNot(value: boolean)` | Equal / not equal |
63
53
  | `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
64
54
 
65
- ### `q.date(field)`
55
+ ### Date fields
56
+
57
+ `DATE`, `DATETIME`, `CREATED_AT`, `UPDATED_AT`.
66
58
 
67
59
  | Call | Meaning |
68
60
  | --- | --- |
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 |
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 |
77
66
  | `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
78
67
 
79
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.
80
69
 
81
- ### `q.array(field)`
70
+ ### Choice fields
71
+
72
+ `SELECT`, `LINKED_RECORD`, `CREATED_BY`, `UPDATED_BY`.
73
+
74
+ | Call | Meaning |
75
+ | --- | --- |
76
+ | `.is(value)` / `.isNot(value)` | Equal / not equal |
77
+ | `.isOneOf(values)` / `.isNoneOf(values)` | Any of / none of |
78
+ | `.isEmpty()` / `.isNotEmpty()` | No value / has a value |
79
+
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.
81
+
82
+ ### Multi-value fields
82
83
 
83
- For multi-value fields: linked records, multi-selects, collaborators.
84
+ Any generated type with a `[]` suffix: multi-selects, multi-entry linked records.
84
85
 
85
86
  | Call | Meaning |
86
87
  | --- | --- |
@@ -90,21 +91,30 @@ For multi-value fields: linked records, multi-selects, collaborators.
90
91
  | `.hasAllOf(values: string[])` | Contains all of |
91
92
  | `.isEmpty()` / `.isNotEmpty()` | Empty / non-empty |
92
93
 
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
+
93
104
  ## Combining
94
105
 
95
106
  ```ts
96
- q.and(...filters) // all must match
97
- q.or(...filters) // any may match
107
+ q.$and(...filters) // all must match
108
+ q.$or(...filters) // any may match
98
109
  ```
99
110
 
100
111
  Both take filters or nested groups:
101
112
 
102
113
  ```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']),
114
+ .where((q) =>
115
+ q.$and(
116
+ q.status.is('active'),
117
+ q.$or(q.amount.gte(1000), q.title.contains('priority')),
108
118
  ),
109
119
  )
110
120
  ```
@@ -114,31 +124,17 @@ Nesting goes two levels, a group inside a group. A third level throws `Cannot ne
114
124
  ## Sorting
115
125
 
116
126
  ```ts
117
- orderBy: q.asc('title')
118
- orderBy: [q.desc('publishedAt'), q.asc('title')]
127
+ .orderBy((q) => q.title.asc)
128
+ .orderBy((q) => [q.publishedAt.desc, q.title.asc])
119
129
  ```
120
130
 
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.
131
+ Each field carries `.asc` and `.desc` handles and nothing else. An array applies sorts in order. Repeated `.orderBy` calls append.
135
132
 
136
133
  ## Errors
137
134
 
138
135
  | Message | Cause |
139
136
  | --- | --- |
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` |
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 |
142
139
  | `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 |
140
+ | A `TypeError` reading an operator off `undefined` | Filtering or sorting on a field the action does not declare |
@@ -1,31 +0,0 @@
1
- import { ChartOptions, ChartResult, CreateOptions, GetOptions, ListOptions, ListResult, MetricOptions, MetricResult, RecordFieldsFor, SoftrRecord, UpdateOptions } from "../types.js";
2
-
3
- //#region src/core/datasource.d.ts
4
- declare function listRecords<Id extends string, Sel extends Record<string, string> = Record<never, never>>(datasourceId: Id, options?: Omit<ListOptions, 'select'> & {
5
- select?: Sel;
6
- }): Promise<ListResult<SoftrRecord<RecordFieldsFor<Id, Sel>>>>;
7
- declare function getRecord<Id extends string, Sel extends Record<string, string> = Record<never, never>>(datasourceId: Id, options: Omit<GetOptions, 'select'> & {
8
- select?: Sel;
9
- }): Promise<SoftrRecord<RecordFieldsFor<Id, Sel>> | null>;
10
- declare function createRecord<Id extends string, Sel extends Record<string, string> = Record<never, never>>(datasourceId: Id, options: Omit<CreateOptions, 'select' | 'data'> & {
11
- select?: Sel;
12
- data: Partial<RecordFieldsFor<Id, Sel>>;
13
- }): Promise<SoftrRecord<RecordFieldsFor<Id, Sel>>>;
14
- declare function updateRecord<Id extends string, Sel extends Record<string, string> = Record<never, never>>(datasourceId: Id, options: Omit<UpdateOptions, 'select' | 'data'> & {
15
- select?: Sel;
16
- data: Partial<RecordFieldsFor<Id, Sel>>;
17
- }): Promise<SoftrRecord<RecordFieldsFor<Id, Sel>>>;
18
- declare function deleteRecord(datasourceId: string, id: string): Promise<void>;
19
- declare function getMetric(datasourceId: string, options: MetricOptions): Promise<MetricResult>;
20
- declare function getChart(datasourceId: string, options: ChartOptions): Promise<ChartResult>;
21
- declare const ds: {
22
- list: typeof listRecords;
23
- get: typeof getRecord;
24
- create: typeof createRecord;
25
- update: typeof updateRecord;
26
- delete: typeof deleteRecord;
27
- metric: typeof getMetric;
28
- chart: typeof getChart;
29
- };
30
- //#endregion
31
- export { ds };