@softr/sdk 0.0.24 → 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 (62) hide show
  1. package/dist/action.d.ts +195 -0
  2. package/dist/action.js +21 -0
  3. package/dist/assertions.js +1 -7
  4. package/dist/auth/isomorphic.d.ts +1 -2
  5. package/dist/auth/methods/authenticator.d.ts +80 -0
  6. package/dist/auth/methods/authenticator.js +175 -0
  7. package/dist/auth/methods/code.d.ts +40 -0
  8. package/dist/auth/methods/code.js +74 -0
  9. package/dist/auth/methods/magicLink.d.ts +24 -0
  10. package/dist/auth/methods/magicLink.js +53 -0
  11. package/dist/auth/methods/password.d.ts +19 -5
  12. package/dist/auth/methods/password.js +36 -15
  13. package/dist/auth/methods/signup.d.ts +22 -14
  14. package/dist/auth/methods/signup.js +48 -54
  15. package/dist/auth/methods/social.d.ts +35 -0
  16. package/dist/auth/methods/social.js +58 -0
  17. package/dist/auth/methods/utils.d.ts +1 -1
  18. package/dist/auth/permissions.d.ts +7 -2
  19. package/dist/auth/platform.js +185 -7
  20. package/dist/auth/server.d.ts +15 -0
  21. package/dist/auth/server.js +16 -1
  22. package/dist/auth/session.js +35 -2
  23. package/dist/cli.d.ts +1 -0
  24. package/dist/cli.js +23 -0
  25. package/dist/codegen/datasource-types.js +471 -0
  26. package/dist/codegen/fetch-metadata.js +46 -0
  27. package/dist/codegen/softr-env.js +102 -0
  28. package/dist/codegen/user-groups.js +11 -0
  29. package/dist/core/integration.js +1 -1
  30. package/dist/datasource/chart.d.ts +196 -0
  31. package/dist/datasource/chart.js +289 -0
  32. package/dist/datasource/datasource.d.ts +16 -0
  33. package/dist/datasource/datasource.js +17 -0
  34. package/dist/datasource/fields.d.ts +122 -0
  35. package/dist/datasource/fields.js +82 -0
  36. package/dist/datasource/filter.d.ts +30 -0
  37. package/dist/datasource/filter.js +146 -0
  38. package/dist/datasource/record.d.ts +138 -0
  39. package/dist/datasource/record.js +258 -0
  40. package/dist/index.d.ts +3 -5
  41. package/dist/index.js +2 -2
  42. package/dist/package.js +5 -0
  43. package/dist/routes/visibility.d.ts +1 -1
  44. package/dist/server.d.ts +1 -1
  45. package/dist/server.js +1 -1
  46. package/package.json +31 -8
  47. package/skills/softr-sdk/SKILL.md +11 -7
  48. package/skills/softr-sdk/auth/SKILL.md +222 -36
  49. package/skills/softr-sdk/charts-and-metrics/SKILL.md +178 -0
  50. package/skills/softr-sdk/charts-and-metrics/references/aggregations.md +76 -0
  51. package/skills/softr-sdk/current-user/SKILL.md +1 -1
  52. package/skills/softr-sdk/data-mutations/SKILL.md +76 -56
  53. package/skills/softr-sdk/reading-records/SKILL.md +77 -93
  54. package/skills/softr-sdk/reading-records/references/field-types.md +41 -48
  55. package/skills/softr-sdk/reading-records/references/query-builder.md +66 -70
  56. package/skills/softr-sdk/routes-and-visibility/SKILL.md +1 -1
  57. package/dist/core/datasource.d.ts +0 -31
  58. package/dist/core/datasource.js +0 -226
  59. package/dist/core/query.d.ts +0 -354
  60. package/dist/core/query.js +0 -528
  61. package/dist/core/utils.js +0 -14
  62. package/dist/types.d.ts +0 -128
@@ -139,4 +139,4 @@ type VisibilityHandle<R extends RouteVisibility = RouteVisibility> = {
139
139
  */
140
140
  declare function defineVisibility<const R extends RouteVisibility>(rule: R): VisibilityHandle<R>;
141
141
  //#endregion
142
- export { AccessBehaviour, AccessCheck, AccessError, AccessReason, RouteVisibility, VisibilityHandle, defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError };
142
+ export { defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError };
package/dist/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { auth } from "./auth/server.js";
2
- import { ds } from "./core/datasource.js";
2
+ import { ds } from "./datasource/datasource.js";
3
3
  import { workflow } from "./core/workflow.js";
4
4
  import { integration } from "./core/integration.js";
5
5
  import "@tanstack/react-start/server-only";
package/dist/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { auth } from "./auth/server.js";
2
- import { ds } from "./core/datasource.js";
2
+ import { ds } from "./datasource/datasource.js";
3
3
  import { workflow } from "./core/workflow.js";
4
4
  import { integration } from "./core/integration.js";
5
5
  import "@tanstack/react-start/server-only";
package/package.json CHANGED
@@ -1,15 +1,36 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.24",
3
+ "version": "0.0.26-alpha-0",
4
4
  "type": "module",
5
5
  "exports": {
6
- ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
7
- "./client": { "types": "./dist/client.d.ts", "import": "./dist/client.js" },
8
- "./server": { "types": "./dist/server.d.ts", "import": "./dist/server.js" },
9
- "./vite": { "types": "./dist/vite.d.ts", "import": "./dist/vite.js" }
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "import": "./dist/index.js"
9
+ },
10
+ "./client": {
11
+ "types": "./dist/client.d.ts",
12
+ "import": "./dist/client.js"
13
+ },
14
+ "./server": {
15
+ "types": "./dist/server.d.ts",
16
+ "import": "./dist/server.js"
17
+ },
18
+ "./vite": {
19
+ "types": "./dist/vite.d.ts",
20
+ "import": "./dist/vite.js"
21
+ }
22
+ },
23
+ "bin": {
24
+ "softr-sdk": "./dist/cli.js"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "skills",
29
+ "!skills/_artifacts"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
10
33
  },
11
- "files": ["dist", "skills", "!skills/_artifacts"],
12
- "publishConfig": { "access": "public" },
13
34
  "scripts": {
14
35
  "build": "tsdown",
15
36
  "test": "node --import ./src/test-utils/setup.ts --test",
@@ -41,7 +62,9 @@
41
62
  "optional": true
42
63
  }
43
64
  },
44
- "keywords": ["tanstack-intent"],
65
+ "keywords": [
66
+ "tanstack-intent"
67
+ ],
45
68
  "intent": {
46
69
  "version": 1,
47
70
  "repo": "@softr/sdk",
@@ -5,13 +5,13 @@ description: >-
5
5
  four entrypoints (@softr/sdk, /server, /client, /vite) and the client-server
6
6
  boundary between them, softrAuthMiddleware, getSoftrContext,
7
7
  initialSoftrContext, SoftrContext, and which sub-skill to load for route
8
- visibility, the current user, reading records, writing records, or auth. Load
9
- this when unsure which sub-skill applies, or when touching the wiring; each
10
- sub-skill stands alone for its own task.
8
+ visibility, the current user, reading records, charts and metrics, writing
9
+ records, or auth. Load this when unsure which sub-skill applies, or when
10
+ touching the wiring; each sub-skill stands alone for its own task.
11
11
  metadata:
12
12
  type: core
13
13
  library: softr-sdk
14
- library_version: 0.0.24
14
+ library_version: 0.0.25
15
15
  ---
16
16
 
17
17
  # Softr SDK core
@@ -21,7 +21,7 @@ metadata:
21
21
  > **CRITICAL**: The SDK is split across four entrypoints and the split is enforced. `ds` and `auth` exist only on `@softr/sdk/server` and only run inside a `createServerFn` handler. Importing them anywhere else fails the build.
22
22
  > **CRITICAL**: Every route file must declare its audience with `defineVisibility`. There is no default and nothing is inherited from a sibling. A route file without a declaration is a lint error.
23
23
  > **CRITICAL**: The app is already wired. `src/start.ts`, `src/router.tsx` and `src/routes/__root.tsx` register the middleware and resolve the session. Do not add a second registration.
24
- > **CRITICAL**: `workflow.trigger` is exploratory. It exists on the entrypoints below and typechecks, but its endpoint is not final. Do not build a feature on it. Tell the user the capability is not ready and ask how they want to proceed.
24
+ > **CRITICAL**: `workflow.trigger` and `integration.fetch` are exploratory. They exist on the entrypoints below and they typecheck, but their endpoints are not final. Do not build a feature on either of them. Tell the user the capability is not ready and ask how they want to proceed.
25
25
 
26
26
  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 `reading-records` plus `routes-and-visibility`.
27
27
 
@@ -29,7 +29,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
29
29
 
30
30
  | Import | Runs | Holds |
31
31
  | --- | --- | --- |
32
- | `@softr/sdk` | Both | `defineVisibility`, `q`, `useCurrentUser`, `usePermissions`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
32
+ | `@softr/sdk` | Both | `defineAction`, `defineVisibility`, `useCurrentUser`, `usePermissions`, `useUserTimezone`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
33
33
  | `@softr/sdk/server` | Server only | `ds`, `auth`, `workflow`, `integration` |
34
34
  | `@softr/sdk/client` | Browser only | Nothing yet, reserved for browser-only utilities |
35
35
  | `@softr/sdk/vite` | Build | `softr()`, already in `vite.config.ts` |
@@ -41,6 +41,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
41
41
  | Add a route, or change who can see one | [routes-and-visibility/SKILL.md](./routes-and-visibility/SKILL.md) |
42
42
  | Read the signed-in visitor or what they may do | [current-user/SKILL.md](./current-user/SKILL.md) |
43
43
  | Fetch records from a datasource | [reading-records/SKILL.md](./reading-records/SKILL.md) |
44
+ | Chart, aggregate or count records | [charts-and-metrics/SKILL.md](./charts-and-metrics/SKILL.md) |
44
45
  | Create, update or delete a record | [data-mutations/SKILL.md](./data-mutations/SKILL.md) |
45
46
  | Sign a visitor in, out, or up | [auth/SKILL.md](./auth/SKILL.md) |
46
47
 
@@ -56,6 +57,9 @@ Showing the visitor's name, or hiding UI they cannot use?
56
57
  Putting records on a page, in a table, a list or a detail view?
57
58
  load softr-sdk/reading-records
58
59
 
60
+ Drawing a chart, a stat tile, or any aggregated number?
61
+ load softr-sdk/charts-and-metrics
62
+
59
63
  Submitting a form, editing a record, or deleting one?
60
64
  load softr-sdk/data-mutations
61
65
 
@@ -124,4 +128,4 @@ type User = {
124
128
 
125
129
  ## Version
126
130
 
127
- Targets `@softr/sdk` v0.0.24.
131
+ Targets `@softr/sdk` v0.0.25.
@@ -1,30 +1,69 @@
1
1
  ---
2
2
  name: auth
3
3
  description: >-
4
- Sign a Softr visitor in, out, or up from a server function: auth.signIn,
5
- auth.signOut, auth.signUp and auth.confirmSignUp from @softr/sdk/server, the
6
- Result shape they answer with ({ success, data } | { success, error }) and its
7
- codes invalidCredentials, tooManyAttempts, emailTaken, invalidDetails and
8
- invalidCode, the two-step sign-up, and redirecting after a successful sign-in.
9
- The blank template already ships working login and signup pages.
4
+ Sign a Softr visitor in, out or up from a server function: auth.signIn,
5
+ signInWithCode, signInWithGoogle, signInWithMagicLink, signUp, confirmSignUp
6
+ and signOut from @softr/sdk/server, the authenticator-app calls that finish a
7
+ sign-in (verifyAuthenticator, startAuthenticatorSetup,
8
+ confirmAuthenticatorSetup, skipAuthenticatorSetup) and the ones that change an
9
+ authenticator from a settings page (sendAuthenticatorResetCode,
10
+ resetAuthenticator, removeAuthenticator). Covers the Result shape they answer
11
+ with ({ success, data } | { success, error }) and its codes
12
+ invalidCredentials, tooManyAttempts, emailTaken, invalidDetails, invalidCode
13
+ and notAuthenticated, the two-step sign-up, and why a successful sign-in does
14
+ not always mean the visitor is signed in. The blank template already ships
15
+ working login and signup pages.
10
16
  metadata:
11
17
  type: sub-skill
12
18
  library: softr-sdk
13
- library_version: 0.0.24
19
+ library_version: 0.0.25
14
20
  ---
15
21
 
16
22
  **Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineVisibility`, the hooks and every type come from `@softr/sdk`. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
17
23
 
18
24
  # Auth
19
25
 
20
- `auth` is what changes who the visitor is. It exposes four calls and nothing else:
26
+ `auth` is what changes who the visitor is.
27
+
28
+ Signing in:
29
+
30
+ | Call | Does |
31
+ | --- | --- |
32
+ | `auth.signIn({ email, password })` | Signs a visitor in with an email and password |
33
+ | `auth.sendSignInCode({ email })` | Emails a one-time sign-in code |
34
+ | `auth.signInWithCode({ email, code })` | Signs a visitor in with that code |
35
+ | `auth.signInWithGoogle({ email, token, fullName? })` | Signs a visitor in with a Google id token the browser obtained, creating the account the first time |
36
+ | `auth.signInWithMagicLink({ token })` | Signs a visitor in with the token from a magic link they followed |
37
+
38
+ Signing up:
21
39
 
22
40
  | Call | Does |
23
41
  | --- | --- |
24
- | `auth.signIn({ email, password })` | Signs a visitor in |
25
- | `auth.signOut()` | Signs the visitor out |
26
42
  | `auth.signUp({ email, name? })` | Emails a verification code, creates no account |
27
- | `auth.confirmSignUp({ email, password, code, name? })` | Creates the account and signs in |
43
+ | `auth.confirmSignUp({ email, code, name?, password? })` | Creates the account and signs the visitor in |
44
+
45
+ Signing out:
46
+
47
+ | Call | Does |
48
+ | --- | --- |
49
+ | `auth.signOut()` | Ends every session that visitor has in this app, on every device |
50
+
51
+ Finishing a sign-in that wants an authenticator app:
52
+
53
+ | Call | Does |
54
+ | --- | --- |
55
+ | `auth.verifyAuthenticator({ code })` or `({ backupCode })` | Answers the challenge with the code their app shows |
56
+ | `auth.startAuthenticatorSetup()` | Hands back a QR code for enrolling an app |
57
+ | `auth.confirmAuthenticatorSetup({ code })` | Enrols it on its first code, and returns the backup codes once |
58
+ | `auth.skipAuthenticatorSetup()` | Skips enrolment, where the app allows skipping |
59
+
60
+ Changing an authenticator from a settings page:
61
+
62
+ | Call | Does |
63
+ | --- | --- |
64
+ | `auth.sendAuthenticatorResetCode()` | Emails the code the next two ask for |
65
+ | `auth.resetAuthenticator({ code })` | Unpairs the app, so a new one can be enrolled |
66
+ | `auth.removeAuthenticator({ code })` | Turns the authenticator off |
28
67
 
29
68
  **The blank template already ships a working login page and signup page.** Read `src/routes/_auth/login.tsx` before writing anything new. Most tasks are an edit to those, not a new flow.
30
69
 
@@ -57,7 +96,7 @@ A form that posts `FormData` instead validates with a function, since a zod obje
57
96
  .validator((fields: FormData) => Credentials.parse(Object.fromEntries(fields)))
58
97
  ```
59
98
 
60
- `signIn`, `signUp` and `confirmSignUp` answer with a `Result` rather than throwing on something the visitor can fix. `signOut` returns nothing.
99
+ Every call except `signOut` answers with a `Result` rather than throwing on something the visitor can fix. `signOut` returns nothing.
61
100
 
62
101
  ```ts
63
102
  type Result<T> =
@@ -65,14 +104,42 @@ type Result<T> =
65
104
  | { success: false; error: { code: AuthErrorCode; message: string } }
66
105
 
67
106
  type AuthErrorCode =
68
- | 'invalidCredentials' // signIn: wrong email or password
69
- | 'tooManyAttempts' // signIn: rate limited
70
- | 'emailTaken' // signUp or confirmSignUp: that email already has an account
71
- | 'invalidDetails' // signUp: details Softr rejected
72
- | 'invalidCode' // confirmSignUp: wrong or expired verification code
107
+ | 'invalidCredentials' // signIn, signInWithGoogle, signInWithMagicLink: wrong password, or a token Softr would not take
108
+ | 'tooManyAttempts' // any call: rate limited
109
+ | 'emailTaken' // signUp, confirmSignUp: that email already has an account
110
+ | 'invalidDetails' // signUp, sendSignInCode: details Softr rejected
111
+ | 'invalidCode' // any call that takes a code: wrong or expired
112
+ | 'notAuthenticated' // a call that needs a signed-in visitor, made by one who is not
73
113
  ```
74
114
 
75
- Each code comes from the call named beside it. Email and password is the sign-in method these calls cover.
115
+ Branch on `error.code` with a `switch` and write your own wording. Never put `error.message` in front of the visitor.
116
+
117
+ `code` is typed as every code the SDK produces, not the ones a given call can answer, so a `switch` needs a `default` even when the cases above it are complete. Give it a message rather than leaving it out.
118
+
119
+ ## A successful sign-in is not always a session
120
+
121
+ `signIn`, `signInWithCode`, `signInWithGoogle`, `signInWithMagicLink` and `confirmSignUp` all answer the same two ways:
122
+
123
+ ```ts
124
+ { status: 'signedIn', user }
125
+ { status: 'authenticatorRequired', step: 'verify' | 'setup' | 'setupOptional' }
126
+ ```
127
+
128
+ `signedIn` is a session. Redirect into the app.
129
+
130
+ `authenticatorRequired` is not. The app asks for an authenticator app as well, and the visitor has no session until they produce one. Redirecting into the app here sends them to a page they cannot see, and a guarded page bounces them back to the login form with nothing on screen explaining why.
131
+
132
+ Send them on by `step`:
133
+
134
+ | `step` | Show them | Finish with |
135
+ | --- | --- | --- |
136
+ | `verify` | a code field | `auth.verifyAuthenticator({ code })`, or `({ backupCode })` |
137
+ | `setup` | the QR from `auth.startAuthenticatorSetup()` | `auth.confirmAuthenticatorSetup({ code })` |
138
+ | `setupOptional` | the same, plus a skip button | the same, or `auth.skipAuthenticatorSetup()` |
139
+
140
+ Those calls take only what the visitor typed. The sign-in they finish is the one already in progress for this browser, so nothing has to be carried through the UI. It does not stay open indefinitely, and a visitor who abandons the page signs in again from the start.
141
+
142
+ An app with no authenticator app configured never answers `authenticatorRequired`. The type still has both arms either way, so `result.data.user` does not exist until you have narrowed on `status`.
76
143
 
77
144
  ## Core Patterns
78
145
 
@@ -85,19 +152,25 @@ const signIn = createServerFn({ method: 'POST' })
85
152
  const result = await auth.signIn(data)
86
153
 
87
154
  if (!result.success) {
88
- return {
89
- message:
90
- result.error.code === 'tooManyAttempts'
91
- ? 'Too many attempts. Please try again in a few minutes.'
92
- : 'Please ensure your email and password are correct.',
155
+ switch (result.error.code) {
156
+ case 'invalidCredentials':
157
+ return { message: 'Please ensure your email and password are correct.' }
158
+ case 'tooManyAttempts':
159
+ return { message: 'Too many attempts. Please try again in a few minutes.' }
160
+ default:
161
+ return { message: 'Could not sign you in. Please try again.' }
93
162
  }
94
163
  }
95
164
 
165
+ if (result.data.status === 'authenticatorRequired') {
166
+ throw redirect({ to: '/verify', search: { step: result.data.step } })
167
+ }
168
+
96
169
  throw redirect({ to: '/' })
97
170
  })
98
171
  ```
99
172
 
100
- Two exits: return a message for the form to render, or `throw redirect` on success. Do not put `error.message` in front of the visitor. Write your own wording, as above.
173
+ Three exits: a message for the form to render, a redirect to the second factor, a redirect into the app.
101
174
 
102
175
  Call it from the component with `useServerFn`:
103
176
 
@@ -111,6 +184,43 @@ A server function that throws `redirect` needs the hook. Call the imported funct
111
184
 
112
185
  A login page is a `NON_LOGGED_IN_USERS` route, which is what keeps an already-signed-in visitor off it. See [routes-and-visibility](../routes-and-visibility/SKILL.md).
113
186
 
187
+ ### Sign in with an emailed code
188
+
189
+ For an app that signs people in with a code instead of a password.
190
+
191
+ ```ts
192
+ // Step 1: emails the code.
193
+ const sent = await auth.sendSignInCode({ email })
194
+
195
+ // Step 2: same two-armed result as signIn.
196
+ const result = await auth.signInWithCode({ email, code })
197
+ ```
198
+
199
+ Step one succeeds whether or not that address has an account, so the form cannot be used to find out who is registered. Say "check your email" either way, and never branch the UI on it.
200
+
201
+ ### Sign in with Google
202
+
203
+ ```ts
204
+ const result = await auth.signInWithGoogle({
205
+ email,
206
+ token, // the id token Google Identity Services handed the page
207
+ fullName, // names a first-time visitor, ignored for an existing one
208
+ })
209
+ ```
210
+
211
+ Only an id token works. An access token, a token minted for another app, and an email that does not match it all fail as `invalidCredentials`.
212
+
213
+ ### Sign in with a magic link
214
+
215
+ For the route the link in the email points at.
216
+
217
+ ```ts
218
+ // Same two-armed result as signIn.
219
+ const result = await auth.signInWithMagicLink({ token })
220
+ ```
221
+
222
+ The token keeps working after it is used, so anyone holding the link can sign in as that visitor. Never log it, and redirect away from the URL that carried it.
223
+
114
224
  ### Sign out
115
225
 
116
226
  ```ts
@@ -120,7 +230,7 @@ const signOut = createServerFn({ method: 'POST' }).handler(async () => {
120
230
  })
121
231
  ```
122
232
 
123
- It returns nothing and has no failure the visitor can act on, so send them somewhere and let the redirect re-resolve the context.
233
+ It returns nothing and has no failure the visitor can act on, so send them somewhere and let the redirect re-resolve the context. It ends every session that visitor has in this app, on every device, so a "sign out of this device only" button is not something you can build on it.
124
234
 
125
235
  ### Sign up, in two steps
126
236
 
@@ -134,11 +244,15 @@ const startSignUp = createServerFn({ method: 'POST' })
134
244
  .handler(async ({ data }) => {
135
245
  const result = await auth.signUp(data)
136
246
  if (!result.success) {
137
- return {
138
- message:
139
- result.error.code === 'emailTaken'
140
- ? 'That email already has an account.'
141
- : 'Please check the details and try again.',
247
+ switch (result.error.code) {
248
+ case 'emailTaken':
249
+ return { message: 'That email already has an account.' }
250
+ case 'invalidDetails':
251
+ return { message: 'Please check the details and try again.' }
252
+ case 'tooManyAttempts':
253
+ return { message: 'Too many attempts. Please try again in a few minutes.' }
254
+ default:
255
+ return { message: 'Could not start the sign-up. Please try again.' }
142
256
  }
143
257
  }
144
258
  return { message: null } // now show the code field
@@ -150,19 +264,91 @@ const finishSignUp = createServerFn({ method: 'POST' })
150
264
  .handler(async ({ data }) => {
151
265
  const result = await auth.confirmSignUp(data)
152
266
  if (!result.success) {
153
- return {
154
- message:
155
- result.error.code === 'emailTaken'
156
- ? 'That email already has an account. Please sign in instead.'
157
- : 'That code is not valid. Please try again.',
267
+ switch (result.error.code) {
268
+ case 'emailTaken':
269
+ return { message: 'That email already has an account. Please sign in instead.' }
270
+ case 'invalidCode':
271
+ return { message: 'That code is not valid. Please try again.' }
272
+ case 'tooManyAttempts':
273
+ return { message: 'Too many attempts. Please try again in a few minutes.' }
274
+ default:
275
+ return { message: 'Could not finish the sign-up. Please try again.' }
158
276
  }
159
277
  }
160
278
 
279
+ if (result.data.status === 'authenticatorRequired') {
280
+ throw redirect({ to: '/verify', search: { step: result.data.step } })
281
+ }
282
+
161
283
  throw redirect({ to: '/' })
162
284
  })
163
285
  ```
164
286
 
165
- The UI has to carry email and password into step two, plus the name if step one collected one. `name` is optional to `confirmSignUp`, the rest is not. It is the only call that creates the account.
287
+ The UI has to carry email into step two, plus the name if step one collected one. `confirmSignUp` is the only call that creates the account.
288
+
289
+ `password` is what the account is created with. Leave it out only for an app that signs people in with an emailed code. An app that expects a password rejects a sign-up without one, and an app that does not expect one rejects a sign-up that sends it.
290
+
291
+ ### Finishing a sign-in that needs an authenticator app
292
+
293
+ On `step: 'verify'` the visitor already has an app, and owes a code from it:
294
+
295
+ ```ts
296
+ const verify = createServerFn({ method: 'POST' })
297
+ .validator(z.object({ code: z.string() }))
298
+ .handler(async ({ data }) => {
299
+ const result = await auth.verifyAuthenticator(data)
300
+ if (!result.success) {
301
+ switch (result.error.code) {
302
+ case 'invalidCode':
303
+ return { message: 'That code is not valid. Please try again.' }
304
+ case 'tooManyAttempts':
305
+ return { message: 'Too many attempts. Please try again in a few minutes.' }
306
+ default:
307
+ return { message: 'Could not check that code. Please try again.' }
308
+ }
309
+ }
310
+ throw redirect({ to: '/' })
311
+ })
312
+ ```
313
+
314
+ A visitor without their phone sends `{ backupCode }` instead of `{ code }`. Each backup code works once.
315
+
316
+ On `step: 'setup'` or `'setupOptional'` they have no app yet:
317
+
318
+ ```ts
319
+ const start = await auth.startAuthenticatorSetup()
320
+ if (!start.success) return { message: 'Could not start setup. Please try again.' }
321
+
322
+ start.data.otpauthUri // what an authenticator app reads
323
+ start.data.qrPngBase64 // the same URI as a PNG, for
324
+ // <img src={`data:image/png;base64,${qrPngBase64}`} />
325
+ ```
326
+
327
+ Neither of those enrols anybody. `confirmAuthenticatorSetup` does, once it accepts a first code:
328
+
329
+ ```ts
330
+ const done = await auth.confirmAuthenticatorSetup({ code })
331
+ if (done.success) {
332
+ done.data.backupCodes // returned once. Render them now, nothing fetches them again.
333
+ throw redirect({ to: '/' })
334
+ }
335
+ ```
336
+
337
+ Call `startAuthenticatorSetup` again to hand back a fresh QR code, for a visitor who lost the first one. Only the newest one enrols them.
338
+
339
+ `skipAuthenticatorSetup` finishes the sign-in without enrolling, and only on `setupOptional`. Later sign-ins stop asking.
340
+
341
+ ### Changing an authenticator from a settings page
342
+
343
+ ```ts
344
+ await auth.sendAuthenticatorResetCode() // emails the code the next two ask for
345
+ await auth.resetAuthenticator({ code }) // unpairs the app, so a new one can be enrolled
346
+ await auth.removeAuthenticator({ code }) // turns the authenticator off
347
+ ```
348
+
349
+ All three need a signed-in visitor and answer `notAuthenticated` when there is not one. Both changes ask for a code from the visitor's inbox as well, because both change how that visitor signs in.
350
+
351
+ After a reset, enrol the new app with `startAuthenticatorSetup` and `confirmAuthenticatorSetup`, which is also where the new backup codes come from. After a removal, an app that requires an authenticator asks the visitor to set one up again on their next sign-in.
166
352
 
167
353
  ## References
168
354
 
@@ -0,0 +1,178 @@
1
+ ---
2
+ name: charts-and-metrics
3
+ description: >-
4
+ Aggregate Softr datasource records inside a server function: ds.chart with
5
+ groupBy, metrics, where and orderBy for chart rows, ds.metric for a single
6
+ headline number, date bucket intervals, which aggregations each kind of field
7
+ offers, the two-group-column ceiling, and distinct values for filter
8
+ controls. Aggregation only. Listing and fetching records is reading-records.
9
+ metadata:
10
+ type: sub-skill
11
+ library: softr-sdk
12
+ library_version: 0.0.22
13
+ ---
14
+
15
+ This skill builds on softr-sdk, and on reading-records for declaring the action a chart aggregates over.
16
+
17
+ # Charts and metrics
18
+
19
+ `ds.chart` groups records and aggregates each group into one row: the data behind a bar chart, a line, a pie. `ds.metric` aggregates everything into one value: the number on a stat tile. Both live on `@softr/sdk/server` and run inside a `createServerFn` handler, and both take the same read action `ds.list` takes, with the same `where` filters.
20
+
21
+ ## Setup
22
+
23
+ ```ts
24
+ import { createServerFn } from '@tanstack/react-start'
25
+ import { defineAction } from '@softr/sdk'
26
+ import { ds } from '@softr/sdk/server'
27
+
28
+ const sales = defineAction({
29
+ id: 'sales',
30
+ datasource: '8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77',
31
+ operation: 'read',
32
+ fields: {
33
+ amount: 'Vk9cXm2pSz6Ld4', // CURRENCY
34
+ region: 'fZk3Qm9pLw2Xa1', // SELECT
35
+ status: 'Ru7bVx2cNq0Ed9', // SELECT, choice ids 'paid' | 'open' | 'void'
36
+ closedAt: 'Xp2dLh6vKz9Fa3', // DATETIME
37
+ rep: 'Gq5wNt8rJy3Hb1', // LINKED_RECORD
38
+ },
39
+ permission: { userGroup: 'LOGGED_IN_USERS' },
40
+ })
41
+ ```
42
+
43
+ A chart only sees the action's declared fields, so a field to group, aggregate or filter by must be in `fields`.
44
+
45
+ ## Core Patterns
46
+
47
+ ### A bar chart
48
+
49
+ ```ts
50
+ const ordersByRegion = createServerFn().handler(async () => {
51
+ const { data } = await ds
52
+ .chart(sales)
53
+ .groupBy((q) => ({ region: q.region }))
54
+ .metrics((q) => ({ orders: q.$count }))
55
+ .orderBy((q) => q.orders.desc)
56
+ return data
57
+ })
58
+ // data: Array<{ region: string | null; orders: number }>
59
+ ```
60
+
61
+ One row per group, keyed by the names you chose in `groupBy` and `metrics`. `orders` is `number`, not `number | null`: a group that exists holds at least one record. The `null` region is the bucket of records with no value. The result also carries `executionTimestamp: Date`, when the numbers were computed.
62
+
63
+ A SELECT or LINKED_RECORD group column comes back as the choice's display label, not its id. Labels are user-editable and a `where` on the same field compares ids, so a clicked bar's value cannot be fed straight back into a filter.
64
+
65
+ ### A time series
66
+
67
+ ```ts
68
+ const { data } = await ds
69
+ .chart(sales)
70
+ .groupBy((q) => ({ month: q.closedAt.byMonth }))
71
+ .metrics((q) => ({ revenue: q.amount.sum }))
72
+ .where((q) => q.status.is('paid'))
73
+ .orderBy((q) => q.month.asc)
74
+ // data: Array<{ month: string | null; revenue: number | null }>
75
+ ```
76
+
77
+ `q.closedAt` bare groups by the field's value. The bucket leaves hanging off it group a date field by the bucket: `byDay`, `byWeek`, `byMonth`, `byQuarter`, `byYear`, and the cyclic `byHourOfDay`, `byDayOfWeek`, `byMonthOfYear`, `byQuarterOfYear`. What each one holds is in [references/aggregations.md](./references/aggregations.md); `byMonth` reads as `"2025-06"`.
78
+
79
+ **Sort the time axis ascending yourself.** Left unsorted, rows come back by the first bucket ascending when there is one, else by the first metric descending. A time axis that misses the fallback arrives in value order, which draws a scrambled line rather than failing.
80
+
81
+ ### Spread, not just totals
82
+
83
+ ```ts
84
+ const { data } = await ds
85
+ .chart(sales)
86
+ .groupBy((q) => ({ region: q.region }))
87
+ .metrics((q) => ({
88
+ smallest: q.amount.min,
89
+ typical: q.amount.avg,
90
+ biggest: q.amount.max,
91
+ }))
92
+ .orderBy((q) => q.typical.desc)
93
+ ```
94
+
95
+ Any number of metrics in one request, several over the same field included. Each row carries all of them, so one request can feed two panels. A metric key may not reuse a group key, and two metrics may not ask for the same value. `avg` comes back rounded to two decimals.
96
+
97
+ ### Two group columns, which is the ceiling
98
+
99
+ ```ts
100
+ const { data } = await ds
101
+ .chart(sales)
102
+ .groupBy((q) => ({ month: q.closedAt.byMonth, region: q.region }))
103
+ .metrics((q) => ({ revenue: q.amount.sum }))
104
+ .orderBy((q) => [q.month.asc, q.revenue.desc])
105
+ // data: Array<{ month: string | null; region: string | null; revenue: number | null }>
106
+ ```
107
+
108
+ The result is long format, one row per month and region pair, so the app pivots into one array per series before charting. **A chart groups by at most two columns**, each a different field, and a field cannot be grouped and aggregated in the same chart. A third group column is a compile error.
109
+
110
+ ### Distinct values, for a filter control
111
+
112
+ ```ts
113
+ const { data } = await ds.chart(sales).groupBy((q) => ({ region: q.region }))
114
+ // data: Array<{ region: string | null }>
115
+ ```
116
+
117
+ Skip `metrics` entirely and the rows hold the distinct group values, one each. This is the cheapest way to fill a dropdown from a real column.
118
+
119
+ ### A headline number
120
+
121
+ ```ts
122
+ const { value } = await ds
123
+ .metric(sales, (q) => q.amount.sum)
124
+ .where((q) => q.status.is('paid'))
125
+ // value: number | null
126
+ ```
127
+
128
+ One aggregation, no grouping, one value. Whenever the answer is a single number, a stat tile or a count, reach for `ds.metric` rather than a one-row chart: there is nothing to group by and nothing to unpack out of `data`. The same measure handles apply, `q.$count` included:
129
+
130
+ ```ts
131
+ const { value: open } = await ds.metric(sales, (q) => q.$count).where((q) => q.status.is('open'))
132
+ // open: number
133
+ ```
134
+
135
+ ### Top N
136
+
137
+ ```ts
138
+ const { data } = await ds
139
+ .chart(sales)
140
+ .groupBy((q) => ({ rep: q.rep }))
141
+ .metrics((q) => ({ revenue: q.amount.sum }))
142
+ .orderBy((q) => q.revenue.desc)
143
+
144
+ const top5 = data.slice(0, 5)
145
+ ```
146
+
147
+ A chart has no row limit to set, so a top-N is a client-side slice of the fully sorted rows.
148
+
149
+ ### A dashboard load
150
+
151
+ ```ts
152
+ const loadDashboard = createServerFn().handler(async () => {
153
+ const [mtd, series] = await Promise.all([
154
+ ds
155
+ .metric(sales, (q) => q.amount.sum)
156
+ .where((q) => q.$and(q.status.is('paid'), q.closedAt.gte('2026-09-01'))),
157
+ ds
158
+ .chart(sales)
159
+ .groupBy((q) => ({ month: q.closedAt.byMonth }))
160
+ .metrics((q) => ({ revenue: q.amount.sum }))
161
+ .where((q) => q.status.is('paid'))
162
+ .orderBy((q) => q.month.asc),
163
+ ])
164
+ return { mtd: mtd.value, series: series.data }
165
+ })
166
+ ```
167
+
168
+ Each tile and panel is its own request, fired in parallel from one handler.
169
+
170
+ ## Order of calls
171
+
172
+ `groupBy` comes first and exactly once; a chart cannot be awaited before it. `metrics` follows, at most once, or not at all for distinct values. `where` can be chained anywhere, repeatedly, and calling it more than once ands the filters together. `orderBy` sorts by the chart's own keys, group and metric alike, and only sees the keys declared so far, so call it last.
173
+
174
+ ## References
175
+
176
+ - [references/aggregations.md](./references/aggregations.md): every bucket interval and its value type, which aggregations each kind of field offers, and the chart rules
177
+ - [reading-records/SKILL.md](../reading-records/SKILL.md): declaring the action, and the filter operators `where` accepts
178
+ - [reading-records/references/query-builder.md](../reading-records/references/query-builder.md): the full filter matrix