@softr/sdk 0.0.99-se22717.0 → 0.0.99

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,7 +1,7 @@
1
1
  import { User } from "./user.js";
2
2
  import { Formatting } from "../formatting.js";
3
3
  import { SoftrContext } from "./context.js";
4
- import * as react3 from "react";
4
+ import * as react0 from "react";
5
5
  import { ReactNode } from "react";
6
6
  import * as _tanstack_react_start0 from "@tanstack/react-start";
7
7
 
@@ -105,7 +105,7 @@ declare function AuthSettingsProvider({
105
105
  }: {
106
106
  value: AuthOptions;
107
107
  children?: ReactNode;
108
- }): react3.JSX.Element;
108
+ }): react0.JSX.Element;
109
109
  /**
110
110
  * Which ways in this app offers. Requires an {@link AuthSettingsProvider}
111
111
  * above it.
@@ -27,10 +27,8 @@ function makeSortSet(aliases) {
27
27
  /**
28
28
  * Ands the filters of every `where` call into one, which is what an accumulated `where` needs.
29
29
  *
30
- * The backend takes a group inside a group and no deeper, and the docs promise the caller both levels.
31
- * A wrapper added here would spend one of them: a single `where` handing over an AND with an OR inside
32
- * would arrive three deep and be refused. So one filter goes through as it is, and a group that is itself
33
- * an AND contributes its members to the group built here rather than sitting inside it.
30
+ * One filter goes through as it is. A group that is itself an AND contributes its members to the group
31
+ * built here rather than sitting inside it. So the request is no deeper than the filters the caller wrote.
34
32
  */
35
33
  function andAll(filters) {
36
34
  if (filters.length === 1) return filters[0];
@@ -5,62 +5,48 @@ const INTERNAL_KIND = Symbol("KIND");
5
5
  /**
6
6
  * Converts filter utility conditions to a format our backend supports.
7
7
  *
8
- * A leaf becomes a group of one, because the wire format has no way to hold a bare
9
- * condition and a group side by side at the same level.
8
+ * Each group holds its conditions in `filters` and its nested groups in `filterGroups`, at any depth.
9
+ * Both lists are always present. A single condition becomes the only member of an AND group.
10
10
  *
11
11
  * @example
12
12
  * ```tsx
13
- * const json = buildFilterCriteria(fields.title.contains("hello"));
13
+ * const json = buildFilterCriteria(fields.$and(fields.title.contains("hello"), fields.$or(fields.rating.gt(3), fields.rating.isEmpty())));
14
14
  * // result
15
15
  * {
16
- * "filterCriteria": {
17
- * "filterConditionGroup": {
18
- * "logicalOperator": "AND"
19
- * "filterGroups": [{
20
- * "logicalOperator": "AND",
21
- * "filters": [{
22
- * "subject": "title",
23
- * "type": "TEXT",
24
- * "operator": "CONTAINS",
25
- * "value": "hello"
26
- * }],
27
- * }]
28
- * }
16
+ * "filterConditionGroup": {
17
+ * "logicalOperator": "AND",
18
+ * "filters": [{ "subject": "title", "type": "TEXT", "operator": "CONTAINS", "value": "hello" }],
19
+ * "filterGroups": [{
20
+ * "logicalOperator": "OR",
21
+ * "filters": [
22
+ * { "subject": "rating", "type": "NUMERIC", "operator": "GT", "value": "3" },
23
+ * { "subject": "rating", "type": "TEXT", "operator": "IS_EMPTY" }
24
+ * ],
25
+ * "filterGroups": []
26
+ * }]
29
27
  * }
30
- * }
31
- *
28
+ * }
32
29
  * ```
33
30
  */
34
31
  function buildFilterCriteria(input) {
35
- assert(INTERNAL_KIND in input, "a filter must come from the callback a read hands you");
36
- if (isFilter(input)) return { filterConditionGroup: {
37
- logicalOperator: "AND",
38
- filterGroups: [{
39
- logicalOperator: "AND",
40
- filters: [stripInternalKind(input)]
41
- }]
42
- } };
43
- if (isFilterGroup(input)) {
44
- if (input.filters.length === 0) return { filterConditionGroup: {
45
- logicalOperator: input.logicalOperator,
46
- filterGroups: []
47
- } };
48
- return { filterConditionGroup: {
49
- logicalOperator: input.logicalOperator,
50
- filterGroups: input.filters.map((inner) => {
51
- if (isFilter(inner)) return {
52
- logicalOperator: "AND",
53
- filters: [stripInternalKind(inner)]
54
- };
55
- if (!inner.filters.every(isFilter)) throw new Error("Cannot nest filter groups more than three levels deep.");
56
- return {
57
- logicalOperator: inner.logicalOperator,
58
- filters: inner.filters.map(stripInternalKind)
59
- };
60
- })
61
- } };
32
+ return { filterConditionGroup: isFilterGroup(input) ? buildFilterGroup(input.logicalOperator, input.filters) : buildFilterGroup("AND", [input]) };
33
+ }
34
+ function buildFilterGroup(logicalOperator, members) {
35
+ const filters = [];
36
+ const filterGroups = [];
37
+ for (const member of members) {
38
+ assert(INTERNAL_KIND in member, "a filter must come from the callback a read hands you");
39
+ if (isFilterGroup(member)) filterGroups.push(buildFilterGroup(member.logicalOperator, member.filters));
40
+ else {
41
+ assert(isFilter(member), () => "Unknown filter kind: " + String(member[INTERNAL_KIND]));
42
+ filters.push(stripInternalKind(member));
43
+ }
62
44
  }
63
- throw new Error("Unknown filter kind: " + input[INTERNAL_KIND]);
45
+ return {
46
+ logicalOperator,
47
+ filters,
48
+ filterGroups
49
+ };
64
50
  }
65
51
  /**
66
52
  * Converts order-by utility conditions to a format our backend supports
@@ -115,13 +101,14 @@ function dealiasOrderBy(orderbys, aliases) {
115
101
  */
116
102
  function dealiasFilterCriteria(criteria, aliases) {
117
103
  if (!criteria.filterConditionGroup) return criteria;
118
- return { filterConditionGroup: {
119
- ...criteria.filterConditionGroup,
120
- filterGroups: criteria.filterConditionGroup.filterGroups.map((group) => ({
104
+ return { filterConditionGroup: dealiasGroup(criteria.filterConditionGroup) };
105
+ function dealiasGroup(group) {
106
+ return {
121
107
  ...group,
122
- filters: group.filters.map(dealiasFilter)
123
- }))
124
- } };
108
+ filters: group.filters.map(dealiasFilter),
109
+ filterGroups: group.filterGroups.map(dealiasGroup)
110
+ };
111
+ }
125
112
  function dealiasFilter(filter) {
126
113
  const subject = aliases[filter.subject];
127
114
  assert(subject, () => `Could not find an alias for subject "${filter.subject}"`);
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.0.99-se22717.0";
2
+ var version = "0.0.99";
3
3
 
4
4
  //#endregion
5
5
  export { version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.99-se22717.0",
3
+ "version": "0.0.99",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: core
11
11
  library: softr-sdk
12
- library_version: 0.0.99-se22717.0
12
+ library_version: 0.0.99
13
13
  ---
14
14
 
15
15
  # Softr SDK core
@@ -20,11 +20,11 @@ metadata:
20
20
  >
21
21
  > **CRITICAL**: Every route file must declare its settings with `definePage`, guard itself with them in `beforeLoad` and render them in `head`. There is no default and nothing is inherited from a sibling.
22
22
  >
23
- > **CRITICAL**: The app is already wired. `src/start.ts`, `src/router.tsx` and `src/routes/__root.tsx` register `softrAuthMiddleware` and resolve the session. Do not add a second registration of it. `softrMcpMiddleware` is the one thing an app adds to `src/start.ts` itself — load the `mcp` sub-skill before doing so.
23
+ > **CRITICAL**: The app is already wired. `src/start.ts`, `src/router.tsx` and `src/routes/__root.tsx` register `softrAuthMiddleware` and resolve the session. Do not add a second registration of it. `softrMcpMiddleware` is the one thing an app adds to `src/start.ts` itself — load the `softr-sdk/mcp` sub-skill before doing so.
24
24
  >
25
- > **CRITICAL**: `ai.generate` and `ai.stream` live on `@softr/sdk/server` and only run inside a `createServerFn` handler. Use `defineAiAction` from `@softr/sdk` to declare the action with the model it runs on, an id copied from the `ai_list_models` tool. Softr runs the model on its own keys; never ask the user for an API key for the app's own AI feature. Load the `ai` sub-skill for the full pattern.
25
+ > **CRITICAL**: `ai.generate` and `ai.stream` live on `@softr/sdk/server` and only run inside a `createServerFn` handler. Use `defineAiAction` from `@softr/sdk` to declare the action with the model it runs on, an id copied from the `ai_list_models` tool. Softr runs the model on its own keys; never ask the user for an API key for the app's own AI feature. Load the `softr-sdk/ai` sub-skill for the full pattern.
26
26
  >
27
- > **CRITICAL**: `workflow.trigger` lives on `@softr/sdk/server` and only runs inside a `createServerFn` handler. Use `defineWorkflow` from `@softr/sdk` to declare the action, then pass it to `workflow.trigger`. Load the `workflows` sub-skill for the full pattern.
27
+ > **CRITICAL**: `workflow.trigger` lives on `@softr/sdk/server` and only runs inside a `createServerFn` handler. Use `defineWorkflow` from `@softr/sdk` to declare the action, then pass it to `workflow.trigger`. Load the `softr-sdk/workflows` sub-skill for the full pattern.
28
28
 
29
29
  Each sub-skill stands alone for its task and repeats the boundary above, so most work needs **one** sub-skill and not this file. Load this one when you are unsure which sub-skill applies, or when the wiring below is what you are touching. Add a second sub-skill only when the work genuinely crosses that boundary: a page that lists records behind a group restriction needs `record-queries` plus `routes-and-visibility`.
30
30
 
@@ -59,46 +59,46 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
59
59
 
60
60
  ```text
61
61
  Adding a page, or changing who may see one?
62
- load the `routes-and-visibility` sub-skill
62
+ load softr-sdk/routes-and-visibility
63
63
 
64
64
  Hiding a region of a page from some visitors?
65
- load the `region-visibility` sub-skill
65
+ load softr-sdk/region-visibility
66
66
 
67
67
  Showing the visitor's name, email or groups?
68
- load the `current-user` sub-skill
68
+ load softr-sdk/current-user
69
69
 
70
70
  Putting records on a page, in a table, a list or a detail view?
71
- load the `record-queries` sub-skill
71
+ load softr-sdk/record-queries
72
72
 
73
73
  Drawing a chart, a stat tile, or any aggregated number?
74
- load the `charts-and-metrics` sub-skill
74
+ load softr-sdk/charts-and-metrics
75
75
 
76
76
  Submitting a form, editing a record, or deleting one?
77
- load the `record-mutations` sub-skill
77
+ load softr-sdk/record-mutations
78
78
 
79
79
  Triggering a Softr workflow from a button or form?
80
- load the `workflows` sub-skill
80
+ load softr-sdk/workflows
81
81
 
82
82
  Wiring a login, signup, or sign-out?
83
- load the `auth` sub-skill
83
+ load softr-sdk/auth
84
84
 
85
85
  Asking an AI model for anything: a summary, fields out of a document, a classification, a draft, a chat?
86
- load the `ai` sub-skill — Softr runs the model on its own keys, nothing to connect; pick the model from ai_list_models
86
+ load softr-sdk/ai — Softr runs the model on its own keys, nothing to connect; pick the model from ai_list_models
87
87
 
88
88
  Calling a connected third-party API (Slack, Stripe, Gmail, Google Calendar, …)?
89
- load the `integrations` sub-skill
89
+ load softr-sdk/integrations
90
90
 
91
91
  Running a model on the user's OWN provider account, because they chose that for the feature?
92
- load the `integrations` sub-skill: its "Calling an AI model" section
92
+ load softr-sdk/integrations: its "Calling an AI model" section
93
93
 
94
94
  Opening the app to an AI assistant over MCP?
95
- load the `mcp` sub-skill
95
+ load softr-sdk/mcp
96
96
 
97
97
  Adding an install button, or making the app installable?
98
- load the `pwa` sub-skill
98
+ load softr-sdk/pwa
99
99
 
100
100
  Showing a date, a time or a number on a page?
101
- load the `formatting` sub-skill
101
+ load softr-sdk/formatting
102
102
 
103
103
  Writing a server function, loader, or middleware in general?
104
104
  not a Softr concern. Use the TanStack Start skills.
@@ -13,7 +13,7 @@ description: >-
13
13
  metadata:
14
14
  type: sub-skill
15
15
  library: softr-sdk
16
- library_version: 0.0.99-se22717.0
16
+ library_version: 0.0.99
17
17
  ---
18
18
 
19
19
  **Boundary.** `ai` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineAiAction` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.99-se22717.0
12
+ library_version: 0.0.99
13
13
  ---
14
14
 
15
15
  **Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.99-se22717.0
12
+ library_version: 0.0.99
13
13
  ---
14
14
 
15
15
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.99-se22717.0
12
+ library_version: 0.0.99
13
13
  ---
14
14
 
15
15
  # The current user
@@ -73,7 +73,7 @@ Never pass the visitor's id from a loader into a server function. The loader als
73
73
  ```ts
74
74
  import { createServerFn } from '@tanstack/react-start'
75
75
  import { defineQuery } from '@softr/sdk'
76
- import { ds } from '@softr/sdk/server'
76
+ import { auth, ds } from '@softr/sdk/server'
77
77
 
78
78
  const myInvoices = defineQuery({
79
79
  id: 'myInvoices',
@@ -84,12 +84,14 @@ const myInvoices = defineQuery({
84
84
  })
85
85
 
86
86
  const getMyInvoices = createServerFn().handler(async ({ context }) => {
87
- if (!context.user) throw new Error('Not signed in')
88
- const userId = context.user.id
89
- return await ds.list(myInvoices).where((q) => q.owner.is(userId))
87
+ const me = await auth.currentUserRow(context.user, usersQuery, 'email')
88
+ if (me === null) return { records: [], total: 0, nextOffset: null }
89
+ return await ds.list(myInvoices).where((q) => q.owner.isOneOf([me.id]))
90
90
  })
91
91
  ```
92
92
 
93
+ Here `owner` links to Users, and `usersQuery` is the app's query on the Users table. `auth.currentUserRow` answers `null` when the visitor is signed out or has no row. See [record-queries](../record-queries/SKILL.md#keep-each-visitor-to-their-own-records) for the scope every read of the table takes.
94
+
93
95
  `context.user` is typed. Start derives a handler's context from the app's registered request middleware as well as the server function's own, so there is nothing to import and nothing to cast.
94
96
 
95
97
  ### The visitor's role, in a server function
@@ -7,7 +7,7 @@ description: >-
7
7
  metadata:
8
8
  type: sub-skill
9
9
  library: softr-sdk
10
- library_version: 0.0.99-se22717.0
10
+ library_version: 0.0.99
11
11
  ---
12
12
 
13
13
  **Boundary.** `upload` lives on `@softr/sdk/server` and runs inside server functions. `useFileUpload` and `FileItem` live on `@softr/sdk` and run in components. `FileAttachment` comes from `@softr/sdk` and can be imported anywhere.
@@ -8,7 +8,7 @@ description: >-
8
8
  metadata:
9
9
  type: sub-skill
10
10
  library: softr-sdk
11
- library_version: 0.0.99-se22717.0
11
+ library_version: 0.0.99
12
12
  ---
13
13
 
14
14
  # Dates, times and numbers
@@ -7,7 +7,7 @@ description: >-
7
7
  metadata:
8
8
  type: sub-skill
9
9
  library: softr-sdk
10
- library_version: 0.0.99-se22717.0
10
+ library_version: 0.0.99
11
11
  ---
12
12
 
13
13
  **Boundary.** `integration` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineIntegration` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.99-se22717.0
12
+ library_version: 0.0.99
13
13
  ---
14
14
 
15
15
  **Boundary.** `softrMcpMiddleware` lives on `@softr/sdk` and is registered once, in `src/start.ts`. The actions you hand it are the same `defineQuery` / `defineMutation` values the app's own pages use — declared wherever they already live, not duplicated for MCP.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.99-se22717.0
12
+ library_version: 0.0.99
13
13
  ---
14
14
 
15
15
  # Installing the app
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.99-se22717.0
12
+ library_version: 0.0.99
13
13
  ---
14
14
 
15
15
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineMutation` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -114,7 +114,7 @@ Resolves to nothing. A record that cannot be deleted throws.
114
114
 
115
115
  ### Change only the records the visitor may read
116
116
 
117
- When a role reads only some records of a table, it updates and deletes only those records. `permissions` decides who may run the mutation, not which record. The id comes from the browser, so it can name any record. Before the write, read the record through the same query and scope as every read of the table (see [record-queries](../record-queries/SKILL.md#keep-each-visitor-to-their-own-records)). Refuse the write when that read answers `null`, with the same error as a missing record, so the visitor cannot find out which ids exist. Keep the field that the scope filters on, such as the owner, out of the update's `fields`. If it is in `fields`, a visitor can move a record to another person.
117
+ When a role reads only some records of a table, it updates and deletes only those records. `permissions` decides who may run the mutation, not which record. The id comes from the browser, so it can name any record. Before the write, read the record through the same query and scope as every read of the table (see [record-queries](../record-queries/SKILL.md#keep-each-visitor-to-their-own-records)). When the role changes fewer records than it reads, that read filters on the records it may change instead (see [Only the roles that change a table](#only-the-roles-that-change-a-table)). Refuse the write when that read answers `null`, with the same error as a missing record, so the visitor cannot find out which ids exist. Keep the field that the scope filters on, such as the owner, out of the update's `fields`. If it is in `fields`, a visitor can move a record to another person.
118
118
 
119
119
  Below, `bookings` is the query every read of bookings uses, and `usersQuery` is the app's query on the Users table. `updateBooking` declares `status` only, not `client`.
120
120
 
@@ -136,7 +136,7 @@ A delete takes the same read before `ds.delete`. A create into a table that take
136
136
 
137
137
  ### Only the roles that change a table
138
138
 
139
- Often fewer roles change a table than read it. A mutation's `customGroups` names only the roles that may change the table, also when more roles read it. In a barber app, every client reads the free hours, but only barbers open or close them. So the slot update names the barbers' group only, and a barber still changes only their own slots, through the read above:
139
+ Often fewer roles change a table than read it. A mutation's `customGroups` names only the roles that may change the table, also when more roles read it. In a barber app, every client reads the free hours, but only barbers open or close them. So the slot update names the barbers' group only:
140
140
 
141
141
  ```ts
142
142
  const updateSlot = defineMutation({
@@ -148,6 +148,20 @@ const updateSlot = defineMutation({
148
148
  })
149
149
  ```
150
150
 
151
+ A barber also changes only their own slots. Every client reads every slot, so the read scope does not stop one barber from changing another barber's hours. When a role changes fewer records than it reads, the read before the write filters on the records it may change. Here `slots` is the query every read of slots uses, and its `barber` field links to Users:
152
+
153
+ ```ts
154
+ const setSlotOpen = createServerFn({ method: 'POST' })
155
+ .validator(z.object({ id: z.string(), open: z.boolean() }))
156
+ .handler(async ({ context, data }) => {
157
+ const me = await auth.currentUserRow(context.user, usersQuery, 'email')
158
+ if (me === null) throw new Error('Slot not found')
159
+ const slot = await ds.get(slots, { id: data.id }).where((q) => q.barber.isOneOf([me.id]))
160
+ if (slot === null) throw new Error('Slot not found')
161
+ await ds.update(updateSlot, { id: slot.id, data: { open: data.open } })
162
+ })
163
+ ```
164
+
151
165
  ### Edit an existing record
152
166
 
153
167
  An edit form needs both halves: a `defineQuery` to load the record and a `defineMutation` to save it. They are separate declarations over the same table, so give both the fields the form touches, and read the loaded values off the query's names.
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.99-se22717.0
13
+ library_version: 0.0.99
14
14
  ---
15
15
 
16
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. The same holds for any helper that calls `ds`: declare it inside the handler. A function at module level that calls `ds` keeps the server-only import in the browser bundle, and every page stops loading until it moves. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -197,7 +197,7 @@ const getMyBooking = createServerFn()
197
197
  })
198
198
  ```
199
199
 
200
- The filter matches the field that holds the owner. A link to the Users table holds the visitor's row there, not `context.user.id`. `auth.currentUserRow(context.user, usersQuery, 'email')` finds that row by email, or answers `null` when they are signed out or have no row. A `USER` field holds the account itself, so it filters on `context.user.id` after a check for a signed-out visitor. See [current-user](../current-user/SKILL.md) for how the visitor gets onto `context`.
200
+ The filter matches the field that holds the owner. A link to the Users table holds the visitor's row there, not `context.user.id`. `auth.currentUserRow(context.user, usersQuery, 'email')` finds that row by email, or answers `null` when they are signed out or have no row. See [current-user](../current-user/SKILL.md) for how the visitor gets onto `context`.
201
201
 
202
202
  ### A picker over a scoped table
203
203
 
@@ -264,11 +264,13 @@ const listMessages = createServerFn()
264
264
  return await ds
265
265
  .list(messages)
266
266
  .where((q) => q.chat.isOneOf([chat.id]))
267
- .orderBy((q) => q.sentAt.asc)
267
+ .orderBy((q) => q.sentAt.desc)
268
268
  .limit(100)
269
269
  })
270
270
  ```
271
271
 
272
+ The read sorts newest first, so the limit keeps the newest 100 messages. Reverse them in the page to show the oldest on top.
273
+
272
274
  A server function that posts a message reads the chat the same way before `ds.create`.
273
275
 
274
276
  ### Other people's records show only what the role needs
@@ -125,7 +125,7 @@ Both take filters or nested groups:
125
125
  )
126
126
  ```
127
127
 
128
- Nesting goes two levels, a group inside a group. A third level throws `Cannot nest filter groups more than three levels deep.` Flatten by distributing the outer condition rather than nesting further.
128
+ Groups nest to any depth.
129
129
 
130
130
  **An `$or` with no members matches every row.** When its members come from a list that can be empty, return no rows for an empty list instead of reading the table.
131
131
 
@@ -144,5 +144,4 @@ Each field carries `.asc` and `.desc` handles and nothing else. An array applies
144
144
  | --- | --- |
145
145
  | `a filter must come from the callback a read hands you` | A `where` value built as a plain object |
146
146
  | `a sort must come from the callback a read hands you` | An `orderBy` value not from the callback's `.asc` / `.desc` handles |
147
- | `Cannot nest filter groups more than three levels deep.` | A group nested one level too far |
148
147
  | A `TypeError` reading an operator off `undefined` | Filtering or sorting on a field the query does not declare |
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.99-se22717.0
12
+ library_version: 0.0.99
13
13
  ---
14
14
 
15
15
  **Boundary.** `Audience` and `defineVisibility` come from `@softr/sdk` and work anywhere.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.99-se22717.0
12
+ library_version: 0.0.99
13
13
  ---
14
14
 
15
15
  **Boundary.** `definePage`, `pageHead`, the access functions and the error predicates come from `@softr/sdk` and work anywhere.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.99-se22717.0
12
+ library_version: 0.0.99
13
13
  ---
14
14
 
15
15
  **Boundary.** `workflow` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineWorkflow` and every type come from `@softr/sdk` and can be imported anywhere.