@softr/sdk 0.0.39 → 0.0.41-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.
package/dist/cli.js CHANGED
@@ -8,8 +8,9 @@ import path from "node:path";
8
8
  //#region src/cli.ts
9
9
  const OUTPUT = "softr-env.d.ts";
10
10
  async function generate() {
11
- writeFileSync(path.resolve(process.cwd(), OUTPUT), renderSoftrEnv(await fetchSoftrEnvInput(metadataSourceFromEnv()), version), "utf8");
12
- console.log(`[softr] wrote ${OUTPUT}`);
11
+ const input = await fetchSoftrEnvInput(metadataSourceFromEnv());
12
+ writeFileSync(path.resolve(process.cwd(), OUTPUT), renderSoftrEnv(input, version), "utf8");
13
+ console.log(`[softr] wrote ${OUTPUT} (${input.datasources.length} datasources)`);
13
14
  }
14
15
  const [command] = process.argv.slice(2);
15
16
  if (command === "generate") await generate();
package/dist/index.d.ts CHANGED
@@ -3,8 +3,8 @@ import { ActionPermissions, SoftrDatasources, defineIntegration, defineMutation,
3
3
  import { FileAttachment } from "./core/file-upload.js";
4
4
  import { FileItem, useFileUpload } from "./core/use-file-upload.js";
5
5
  import { SoftrContext } from "./auth/context.js";
6
- import { defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError } from "./routes/visibility.js";
6
+ import { defineVisibility, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./routes/visibility.js";
7
7
  import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser, usePermissions } from "./auth/isomorphic.js";
8
8
  import { useUserTimezone } from "./timezone.js";
9
9
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
10
- export { type ActionPermissions, DefaultErrorOverlay, type FileAttachment, type FileItem, type SoftrContext, type SoftrDatasources, type SoftrUserGroupId, type SoftrUserGroups, type User, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, useFileUpload, usePermissions, useUserTimezone };
10
+ export { type ActionPermissions, DefaultErrorOverlay, type FileAttachment, type FileItem, type SoftrContext, type SoftrDatasources, type SoftrUserGroupId, type SoftrUserGroups, type User, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, useFileUpload, usePermissions, useUserTimezone };
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { useFileUpload } from "./core/use-file-upload.js";
2
2
  import { defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
3
- import { defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError } from "./routes/visibility.js";
3
+ import { defineVisibility, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./routes/visibility.js";
4
4
  import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser, usePermissions } from "./auth/isomorphic.js";
5
5
  import { useUserTimezone } from "./timezone.js";
6
6
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
7
7
 
8
- export { DefaultErrorOverlay, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, useFileUpload, usePermissions, useUserTimezone };
8
+ export { DefaultErrorOverlay, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, useFileUpload, usePermissions, useUserTimezone };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.0.39";
2
+ var version = "0.0.41-alpha.0";
3
3
 
4
4
  //#endregion
5
5
  export { version };
@@ -24,9 +24,9 @@ type RouteVisibility = {
24
24
  *
25
25
  * - `unauthenticated` — the route needs a signed-in visitor and there is none.
26
26
  * - `forbidden` — signed in, but in none of the groups the route names.
27
- * - `alreadySignedIn` — the route is for signed-out visitors only.
27
+ * - `alreadyAuthenticated` — the route is for signed-out visitors only.
28
28
  */
29
- declare const ACCESS_REASONS: readonly ["unauthenticated", "forbidden", "alreadySignedIn"];
29
+ declare const ACCESS_REASONS: readonly ["unauthenticated", "forbidden", "alreadyAuthenticated"];
30
30
  type AccessReason = (typeof ACCESS_REASONS)[number];
31
31
  /**
32
32
  * What a guard does when it refuses a visitor: redirect to a path, or throw an
@@ -78,7 +78,7 @@ declare function isUnauthenticatedError(error: unknown): error is AccessError;
78
78
  /** True for an access error thrown because the visitor was in none of the groups. */
79
79
  declare function isForbiddenError(error: unknown): error is AccessError;
80
80
  /** True for an access error thrown because a signed-out-only route saw a user. */
81
- declare function isAlreadySignedInError(error: unknown): error is AccessError;
81
+ declare function isAlreadyAuthenticatedError(error: unknown): error is AccessError;
82
82
  type Narrowed<R extends RouteVisibility> = R extends {
83
83
  userGroup: "LOGGED_IN_USERS";
84
84
  } ? AccessContext & {
@@ -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 { defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError };
142
+ export { defineVisibility, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError };
@@ -1,3 +1,4 @@
1
+ import { panic } from "../assertions.js";
1
2
  import { redirect } from "@tanstack/react-router";
2
3
 
3
4
  //#region src/routes/visibility.ts
@@ -6,12 +7,12 @@ import { redirect } from "@tanstack/react-router";
6
7
  *
7
8
  * - `unauthenticated` — the route needs a signed-in visitor and there is none.
8
9
  * - `forbidden` — signed in, but in none of the groups the route names.
9
- * - `alreadySignedIn` — the route is for signed-out visitors only.
10
+ * - `alreadyAuthenticated` — the route is for signed-out visitors only.
10
11
  */
11
12
  const ACCESS_REASONS = [
12
13
  "unauthenticated",
13
14
  "forbidden",
14
- "alreadySignedIn"
15
+ "alreadyAuthenticated"
15
16
  ];
16
17
  /** Decides whether a visitor passes a visibility rule, without throwing. */
17
18
  function checkAccess(rule, context) {
@@ -20,7 +21,7 @@ function checkAccess(rule, context) {
20
21
  case "ALL_USERS": return { ok: true };
21
22
  case "NON_LOGGED_IN_USERS": return user === null ? { ok: true } : {
22
23
  ok: false,
23
- reason: "alreadySignedIn"
24
+ reason: "alreadyAuthenticated"
24
25
  };
25
26
  case "LOGGED_IN_USERS": {
26
27
  if (user === null) return {
@@ -63,23 +64,12 @@ function isForbiddenError(error) {
63
64
  return isReason(error, "forbidden");
64
65
  }
65
66
  /** True for an access error thrown because a signed-out-only route saw a user. */
66
- function isAlreadySignedInError(error) {
67
- return isReason(error, "alreadySignedIn");
67
+ function isAlreadyAuthenticatedError(error) {
68
+ return isReason(error, "alreadyAuthenticated");
68
69
  }
69
- const DEFAULT_ACCESS_BEHAVIOUR = {
70
- unauthenticated: {
71
- strategy: "redirect",
72
- to: "/login"
73
- },
74
- forbidden: { strategy: "throw" },
75
- alreadySignedIn: {
76
- strategy: "redirect",
77
- to: "/"
78
- }
79
- };
80
70
  /** What a guard throws for a reason: a redirect, or an {@link AccessError}. */
81
71
  function thrownFor(reason, overrides) {
82
- const behaviour = overrides?.[reason] ?? DEFAULT_ACCESS_BEHAVIOUR[reason];
72
+ const behaviour = overrides?.[reason] ?? (typeof __SOFTR_CONFIG__ === "undefined" ? panic("softr.json is missing, so no guard behaviour is configured") : __SOFTR_CONFIG__.auth.guard[reason]);
83
73
  const resolved = typeof behaviour === "string" ? {
84
74
  strategy: "redirect",
85
75
  to: behaviour
@@ -122,4 +112,4 @@ function defineVisibility(rule) {
122
112
  }
123
113
 
124
114
  //#endregion
125
- export { defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError };
115
+ export { defineVisibility, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError };
@@ -0,0 +1,19 @@
1
+ import { z } from "zod";
2
+
3
+ //#region src/softr-config.ts
4
+ const AccessBehaviour = z.union([
5
+ z.string(),
6
+ z.object({
7
+ strategy: z.literal("redirect"),
8
+ to: z.string()
9
+ }),
10
+ z.object({ strategy: z.literal("throw") })
11
+ ], { error: "must be a path like \"/login\", { \"strategy\": \"redirect\", \"to\": \"/login\" } or { \"strategy\": \"throw\" }" });
12
+ const SoftrConfigSchema = z.object({ auth: z.strictObject({ guard: z.strictObject({
13
+ unauthenticated: AccessBehaviour,
14
+ forbidden: AccessBehaviour,
15
+ alreadyAuthenticated: AccessBehaviour
16
+ }) }) });
17
+
18
+ //#endregion
19
+ export { SoftrConfigSchema };
package/dist/vite.d.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { PluginOption } from "vite";
2
2
 
3
3
  //#region src/vite.d.ts
4
- /** Everything Softr adds to an app's Vite config: the studio edit engine, when there is a pod. */
5
4
  declare function softr(): PluginOption[];
6
5
  //#endregion
7
6
  export { softr };
package/dist/vite.js CHANGED
@@ -1,10 +1,45 @@
1
+ import { assert, panic } from "./assertions.js";
2
+ import { SoftrConfigSchema } from "./softr-config.js";
3
+ import { existsSync, readFileSync } from "node:fs";
1
4
  import path from "node:path";
5
+ import { z } from "zod";
2
6
  import { pathToFileURL } from "node:url";
3
7
 
4
8
  //#region src/vite.ts
5
- /** Everything Softr adds to an app's Vite config: the studio edit engine, when there is a pod. */
9
+ const SOFTR_CONFIG_FILE = "softr.json";
6
10
  function softr() {
7
- return [editEngine()];
11
+ return [softrConfig(), editEngine()];
12
+ }
13
+ function softrConfig() {
14
+ let root;
15
+ return {
16
+ name: "softr:config",
17
+ config(userConfig) {
18
+ root = path.resolve(userConfig.root ?? process.cwd());
19
+ return { define: { __SOFTR_CONFIG__: JSON.stringify(loadSoftrConfig(root)) } };
20
+ },
21
+ configureServer(server) {
22
+ const file = path.join(root, SOFTR_CONFIG_FILE);
23
+ server.watcher.add(file);
24
+ server.watcher.on("change", (changed) => {
25
+ if (changed === file) server.restart();
26
+ });
27
+ }
28
+ };
29
+ }
30
+ function loadSoftrConfig(root) {
31
+ const file = path.join(root, SOFTR_CONFIG_FILE);
32
+ assert(existsSync(file), `${SOFTR_CONFIG_FILE} is missing from ${root}`);
33
+ const source = readFileSync(file, "utf8");
34
+ let parsed;
35
+ try {
36
+ parsed = JSON.parse(source);
37
+ } catch (error) {
38
+ panic(`${SOFTR_CONFIG_FILE} is not valid JSON: ${error instanceof Error ? error.message : error}`);
39
+ }
40
+ const result = SoftrConfigSchema.safeParse(parsed);
41
+ if (!result.success) panic(`${SOFTR_CONFIG_FILE} does not match the schema:\n\n${z.prettifyError(result.error)}`);
42
+ return result.data;
8
43
  }
9
44
  /**
10
45
  * The edit engine is provided by the pod runtime rather than an app dependency: it is never in the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.39",
3
+ "version": "0.0.41-alpha.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
@@ -9,7 +9,7 @@
9
9
  "./vite": { "types": "./dist/vite.d.ts", "import": "./dist/vite.js" }
10
10
  },
11
11
  "bin": { "softr-sdk": "./dist/cli.js" },
12
- "files": ["dist", "skills", "!skills/_artifacts"],
12
+ "files": ["dist", "schema", "skills", "!skills/_artifacts"],
13
13
  "publishConfig": { "access": "public" },
14
14
  "scripts": {
15
15
  "build": "tsdown",
@@ -0,0 +1,58 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "softr.json",
4
+ "type": "object",
5
+ "properties": {
6
+ "auth": {
7
+ "type": "object",
8
+ "description": "Authentication. Written in this repo and read by the app's build.",
9
+ "properties": {
10
+ "guard": {
11
+ "type": "object",
12
+ "description": "What a route guard does when it denies a visitor. A route's own overrides win over these.",
13
+ "properties": {
14
+ "unauthenticated": {
15
+ "$ref": "#/$defs/accessBehaviour",
16
+ "description": "The route needs a signed-in visitor and there is none. Defaults to a redirect to /login."
17
+ },
18
+ "alreadyAuthenticated": {
19
+ "$ref": "#/$defs/accessBehaviour",
20
+ "description": "The route is for signed-out visitors only. Defaults to a redirect to /."
21
+ },
22
+ "forbidden": {
23
+ "$ref": "#/$defs/accessBehaviour",
24
+ "description": "Signed in, but in none of the groups the route names. Defaults to throwing, for the route's errorComponent to render."
25
+ }
26
+ },
27
+ "required": ["unauthenticated", "alreadyAuthenticated", "forbidden"],
28
+ "additionalProperties": false
29
+ }
30
+ },
31
+ "required": ["guard"],
32
+ "additionalProperties": false
33
+ }
34
+ },
35
+ "required": ["auth"],
36
+ "$defs": {
37
+ "accessBehaviour": {
38
+ "oneOf": [
39
+ { "type": "string", "description": "A path to redirect to." },
40
+ {
41
+ "type": "object",
42
+ "properties": {
43
+ "strategy": { "const": "redirect" },
44
+ "to": { "type": "string" }
45
+ },
46
+ "required": ["strategy", "to"],
47
+ "additionalProperties": false
48
+ },
49
+ {
50
+ "type": "object",
51
+ "properties": { "strategy": { "const": "throw" } },
52
+ "required": ["strategy"],
53
+ "additionalProperties": false
54
+ }
55
+ ]
56
+ }
57
+ }
58
+ }
@@ -11,7 +11,7 @@ description: >-
11
11
  metadata:
12
12
  type: core
13
13
  library: softr-sdk
14
- library_version: 0.0.39
14
+ library_version: 0.0.41-alpha.0
15
15
  ---
16
16
 
17
17
  # Softr SDK core
@@ -16,7 +16,7 @@ description: >-
16
16
  metadata:
17
17
  type: sub-skill
18
18
  library: softr-sdk
19
- library_version: 0.0.39
19
+ library_version: 0.0.41-alpha.0
20
20
  ---
21
21
 
22
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.
@@ -353,5 +353,5 @@ After a reset, enrol the new app with `startAuthenticatorSetup` and `confirmAuth
353
353
  ## References
354
354
 
355
355
  - [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor these calls establish
356
- - [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): `NON_LOGGED_IN_USERS` and the `alreadySignedIn` redirect that keeps a signed-in visitor off a login page
356
+ - [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): `NON_LOGGED_IN_USERS` and the `alreadyAuthenticated` redirect that keeps a signed-in visitor off a login page
357
357
  - `src/routes/_auth/login.tsx` and `signup.tsx` in your own app: the implementations it was scaffolded with
@@ -12,7 +12,7 @@ description: >-
12
12
  metadata:
13
13
  type: sub-skill
14
14
  library: softr-sdk
15
- library_version: 0.0.39
15
+ library_version: 0.0.41-alpha.0
16
16
  ---
17
17
 
18
18
  **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. 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.
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.39
13
+ library_version: 0.0.41-alpha.0
14
14
  ---
15
15
 
16
16
  **Boundary.** `useCurrentUser`, `usePermissions` and every type come from `@softr/sdk` and work anywhere; `ds` and `auth` live on `@softr/sdk/server` and run only inside a `createServerFn` handler. The app is already wired — `src/start.ts` registers `softrAuthMiddleware`, `src/routes/__root.tsx` publishes the visitor as route context — never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.39
12
+ library_version: 0.0.41-alpha.0
13
13
  ---
14
14
 
15
15
  **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.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.39
12
+ library_version: 0.0.41-alpha.0
13
13
  ---
14
14
 
15
15
  **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. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
@@ -11,7 +11,7 @@ description: >-
11
11
  metadata:
12
12
  type: sub-skill
13
13
  library: softr-sdk
14
- library_version: 0.0.39
14
+ library_version: 0.0.41-alpha.0
15
15
  ---
16
16
 
17
17
  **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. 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.
@@ -133,6 +133,10 @@ await ds.update(updateProject, {
133
133
 
134
134
  A read answers a SELECT as `{ id, label }` and a write takes `{ id }`, so pass the id through rather than the whole value.
135
135
 
136
+ ### Form dropdowns for linked record fields
137
+
138
+ A form select for a `LINKED_RECORD` field needs its options from the linked table, not from the record being edited. Query the linked table with its own `defineQuery` and `ds.list`; each result's `id` is what you pass to the write payload. See [record-queries](../record-queries/SKILL.md).
139
+
136
140
  ### What a write accepts
137
141
 
138
142
  Each key of `data` accepts exactly what its field takes (see [record-queries](../record-queries/SKILL.md#typing)):
@@ -5,14 +5,15 @@ description: >-
5
5
  the where and orderBy callbacks, field names of your own choosing, the
6
6
  20-record default and 100-record cap, nextOffset pagination, and scoping a
7
7
  read to the signed-in visitor. Use when putting datasource records on a page,
8
- in a table, a list or a detail view, when filtering or sorting them, or when a
9
- read comes back empty, unsorted or missing a field. Reads only: creating,
10
- updating and deleting records is record-mutations, and aggregating them into
11
- totals or chart rows is charts-and-metrics.
8
+ in a table, a list or a detail view, when filtering or sorting them, when
9
+ filling a filter or form dropdown with all options from a linked table, or
10
+ when a read comes back empty, unsorted or missing a field. Reads only:
11
+ creating, updating and deleting records is record-mutations, and aggregating
12
+ them into totals or chart rows is charts-and-metrics.
12
13
  metadata:
13
14
  type: sub-skill
14
15
  library: softr-sdk
15
- library_version: 0.0.39
16
+ library_version: 0.0.41-alpha.0
16
17
  ---
17
18
 
18
19
  **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. 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.
@@ -146,6 +147,38 @@ const listMyInvoices = createServerFn().handler(async ({ context }) => {
146
147
 
147
148
  The SDK enforces `permissions`, so a visitor outside the declared audience never reaches the records. Which of those records belong to one visitor is a separate question, and the `where` above answers it: a group says nothing about who owns a row, so a per-visitor read filters on `context.user`. See [current-user](../current-user/SKILL.md) for how the visitor gets onto `context`.
148
149
 
150
+ ### Populate a control from a linked table
151
+
152
+ A `LINKED_RECORD` field on a record answers with `{ id, label }` — the label is the linked record's primary field, and the id is what a write or a `where` filter takes. To fill a filter dropdown or form select with **all** options from the linked table (not just the values already referenced by existing records), query the linked table's own datasource:
153
+
154
+ ```ts
155
+ // "status" on the products table is a LINKED_RECORD pointing to the Product Statuses table
156
+ const productStatuses = defineQuery({
157
+ id: 'productStatuses',
158
+ datasourceId: '00000000-0000-0000-0000-000000000001', // the linked table's datasource id
159
+ fields: { name: 'fZk3Qm9pLw2Xa1' },
160
+ permissions: { userGroup: 'ALL_USERS' },
161
+ })
162
+
163
+ const listProductStatuses = createServerFn().handler(async () => {
164
+ return await ds.list(productStatuses).limit(100)
165
+ })
166
+ ```
167
+
168
+ Each returned record is one option: `record.id` is the value a `where` filter and a mutation payload take; `record.fields.name` is what you display.
169
+
170
+ ```tsx
171
+ // Filter dropdown
172
+ {statuses.map((s) => (
173
+ <option key={s.id} value={s.id}>{s.fields.name}</option>
174
+ ))}
175
+
176
+ // Filtering the main table by the selected linked record
177
+ ds.list(products).where((q) => q.status.is(selectedStatusId))
178
+ ```
179
+
180
+ Skip this request when the options are predefined and fixed — hardcode them instead.
181
+
149
182
  A read the datasource turns down 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.
150
183
 
151
184
  ## Typing
@@ -6,13 +6,13 @@ description: >-
6
6
  four ways to enforce a rule (guard, assert, isAuthorized, check). Covers
7
7
  per-reason overrides, the redirect and throw strategies, rendering an access
8
8
  error with isAccessError, isUnauthenticatedError, isForbiddenError and
9
- isAlreadySignedInError, guarding a layout, and the lint rule that requires a
9
+ isAlreadyAuthenticatedError, guarding a layout, and the lint rule that requires a
10
10
  declaration alongside the rules that reject a duplicate, computed or
11
11
  root-level one.
12
12
  metadata:
13
13
  type: sub-skill
14
14
  library: softr-sdk
15
- library_version: 0.0.39
15
+ library_version: 0.0.41-alpha.0
16
16
  ---
17
17
 
18
18
  **Boundary.** `defineVisibility`, the error predicates and every type come from `@softr/sdk` and work anywhere; `ds` and `auth` live on `@softr/sdk/server` and run only inside a `createServerFn` handler. The app is already wired — `src/start.ts` registers `softrAuthMiddleware`, `src/routes/__root.tsx` publishes the visitor as route context — never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
@@ -82,7 +82,7 @@ export const Route = createFileRoute('/_app')({
82
82
 
83
83
  The whole match chain runs, so a layout guards everything beneath it. Sibling routes do not guard each other. `/admin/index.tsx` does nothing for `/admin/users.tsx` unless an `admin.tsx` layout exists. Routes below a guarded layout still declare their own rule, because the declaration is what Studio reads.
84
84
 
85
- A thrown error renders in place of the guarded route's component. The template's `_app.tsx` shell is guarded with `LOGGED_IN_USERS`, whose default for a signed-out visitor is a redirect, so nothing ever renders there. A rule that can *throw* — a `userGroupIds` rule, whose `forbidden` default is throw — should not sit on the shell itself, or the refusal replaces the nav and chrome. Put it on a layout inside the shell, or on the restricted routes, so the refusal renders inside the chrome.
85
+ A thrown error renders in place of the guarded route's component. A shell layout guarded with `LOGGED_IN_USERS`, where `unauthenticated` redirects, sends a signed-out visitor away before anything renders there. A rule that can *throw* — a `userGroupIds` rule with `forbidden` set to throw — should not sit on the shell itself, or the error replaces the nav and chrome. Put it on a layout inside the shell, or on the restricted routes, so the error renders inside the chrome.
86
86
 
87
87
  ### Change what a denied visitor gets
88
88
 
@@ -99,15 +99,30 @@ beforeLoad: visibility.guard({
99
99
 
100
100
  The long redirect form and the bare string do the same thing.
101
101
 
102
- The defaults, when no override is given:
102
+ Every app declares where a denied visitor goes in `softr.json`, at the app root, and every app has one:
103
+
104
+ ```json
105
+ {
106
+ "$schema": "./node_modules/@softr/sdk/schema/softr.schema.json",
107
+ "auth": {
108
+ "guard": {
109
+ "unauthenticated": "/login",
110
+ "alreadyAuthenticated": "/",
111
+ "forbidden": { "strategy": "throw" }
112
+ }
113
+ }
114
+ }
115
+ ```
116
+
117
+ All three reasons are required, because nothing fills in a reason the file leaves out:
103
118
 
104
- | Reason | Condition | Default |
105
- | --- | --- | --- |
106
- | `unauthenticated` | Route needs a visitor, none present | Redirect to `/login` |
107
- | `forbidden` | Signed in, in none of the named groups | Throw, renders in place |
108
- | `alreadySignedIn` | Signed-in visitor on a signed-out-only route | Redirect to `/` |
119
+ | Reason | Condition |
120
+ | --- | --- |
121
+ | `unauthenticated` | Route needs a visitor, none present |
122
+ | `forbidden` | Signed in, in none of the named groups |
123
+ | `alreadyAuthenticated` | Signed-in visitor on a signed-out-only route |
109
124
 
110
- `ALL_USERS` admits everyone, so it never redirects and never throws. Only `strategy: 'throw'` reaches an `errorComponent`. A redirect navigates away and the route never renders.
125
+ That file covers every route at once; a route's own `guard()` overrides win over it. `ALL_USERS` admits everyone, so it never redirects and never throws. Only `strategy: 'throw'` reaches an `errorComponent`. A redirect navigates away and the route never renders.
111
126
 
112
127
  ### Render the error in place
113
128
 
@@ -133,11 +148,11 @@ export const Route = createFileRoute('/_app/billing')({
133
148
 
134
149
  The error carries a `type` discriminant and its `reason`, nothing else. It never names the groups the route wanted, which would hand that list to the one visitor who was just turned away.
135
150
 
136
- Use the predicates: `isAccessError` for any of the three, and `isUnauthenticatedError`, `isForbiddenError`, `isAlreadySignedInError` for one each. The error is plain data rather than an `Error` subclass, so `instanceof` never matches and there is no `message` to read. That is also why the parameter is annotated `error: unknown`. The router types it as `Error`, and the wider annotation is what lets the predicates narrow it. Leave it as it is.
151
+ Use the predicates: `isAccessError` for any of the three, and `isUnauthenticatedError`, `isForbiddenError`, `isAlreadyAuthenticatedError` for one each. The error is plain data rather than an `Error` subclass, so `instanceof` never matches and there is no `message` to read. That is also why the parameter is annotated `error: unknown`. The router types it as `Error`, and the wider annotation is what lets the predicates narrow it. Leave it as it is.
137
152
 
138
153
  ### Run the check and then keep going
139
154
 
140
- `guard()` covers almost everything. When a route needs its own logic afterwards, `assert()` applies the same rule and throws the same access error, using the default behaviour for each reason. It takes no overrides.
155
+ `guard()` covers almost everything. When a route needs its own logic afterwards, `assert()` applies the same rule and throws the same access error, using `softr.json` for each reason. It takes no overrides.
141
156
 
142
157
  `assert()` guarantees the visitor at runtime but does not narrow `context.user` for the compiler. `isAuthorized()` is the one that narrows. It is a type predicate, so inside its `true` branch `context.user` is `User` under `LOGGED_IN_USERS` and `null` under `NON_LOGGED_IN_USERS`.
143
158
 
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.39
12
+ library_version: 0.0.41-alpha.0
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. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.