@softr/sdk 0.0.20 → 0.0.21

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.
@@ -18,7 +18,7 @@ import * as _tanstack_react_start0 from "@tanstack/react-start";
18
18
  * requestMiddleware: [softrAuthMiddleware()],
19
19
  * }))
20
20
  */
21
- declare function softrAuthMiddleware(): _tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, {
21
+ declare function softrAuthMiddleware(): _tanstack_react_start0.RequestMiddlewareAfterServer<{}, readonly [_tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>], {
22
22
  user: User | null;
23
23
  permissions: Permissions;
24
24
  }>;
@@ -48,7 +48,7 @@ declare function initialSoftrContext(): SoftrContext;
48
48
  * @example
49
49
  * function Greeting() {
50
50
  * const user = useCurrentUser()
51
- * return <p>{user ? `Hello, ${user.name}` : "Please sign in"}</p>
51
+ * return <p>{user ? `Hello, ${user.fullName}` : "Please sign in"}</p>
52
52
  * }
53
53
  */
54
54
  declare function useCurrentUser(): User | null;
@@ -2,6 +2,7 @@ import { assert, panic, requireDefined } from "../assertions.js";
2
2
  import { writeCookies } from "../cookies.js";
3
3
  import { sessionConfig } from "./config.js";
4
4
  import { makePlatform } from "./platform.js";
5
+ import { bootErrorMiddleware } from "../boot-error.js";
5
6
  import { clearSession, makeSessionDataCookie, readSessionData, readSessionToken } from "./session.js";
6
7
  import { rootRouteId, useRouteContext } from "@tanstack/react-router";
7
8
  import { createMiddleware, createServerFn, getGlobalStartContext } from "@tanstack/react-start";
@@ -20,7 +21,7 @@ import { createMiddleware, createServerFn, getGlobalStartContext } from "@tansta
20
21
  * }))
21
22
  */
22
23
  function softrAuthMiddleware() {
23
- return createMiddleware({ type: "request" }).server(async ({ next }) => {
24
+ return createMiddleware({ type: "request" }).middleware([bootErrorMiddleware()]).server(async ({ next }) => {
24
25
  async function getSession() {
25
26
  const token = readSessionToken();
26
27
  const cached = readSessionData(token);
@@ -77,7 +78,7 @@ function initialSoftrContext() {
77
78
  * @example
78
79
  * function Greeting() {
79
80
  * const user = useCurrentUser()
80
- * return <p>{user ? `Hello, ${user.name}` : "Please sign in"}</p>
81
+ * return <p>{user ? `Hello, ${user.fullName}` : "Please sign in"}</p>
81
82
  * }
82
83
  */
83
84
  function useCurrentUser() {
@@ -1,4 +1,4 @@
1
- import { panic, softAssert, todo } from "../../assertions.js";
1
+ import { panic, softAssert } from "../../assertions.js";
2
2
  import { writeCookies } from "../../cookies.js";
3
3
  import { sessionConfig } from "../config.js";
4
4
  import { makePlatform } from "../platform.js";
@@ -10,26 +10,20 @@ import { authError } from "./utils.js";
10
10
  * Signs a visitor in with an email and password.
11
11
  */
12
12
  async function signIn(credentials) {
13
- const platform = makePlatform(sessionConfig().applicationId);
14
- const result = await platform.signIn(credentials);
13
+ const result = await makePlatform(sessionConfig().applicationId).tokens(credentials);
15
14
  if (!result.ok) {
16
15
  if (result.status === 401 || result.status === 429) return authError(result.status === 401 ? "invalidCredentials" : "tooManyAttempts", result.message);
17
16
  if (result.status >= 500) panic(result.message);
18
- if (result.status === 400) todo(result.message);
19
17
  throw new Error(`${result.message}. Check SOFTR_APPLICATION_ID and that the app allows sign-in.`);
20
18
  }
21
- const body = result.data;
22
- if (body?.authenticatorAppAction) todo(`Sign-in needs an authenticator step (${body.authenticatorAppAction}), which the SDK cannot finish yet.`);
23
- const token = body?.jwt;
24
- if (!token) todo("This app is configured for passwordless sign-in, which the SDK cannot finish yet.");
25
- const sessionResult = await platform.session(token);
26
- if (!sessionResult.ok) panic(sessionResult.message);
27
- const session = sessionResult.data;
28
- if (session.user === null) panic("Session token issued, but no user came back with it.");
29
- writeCookies([makeSessionTokenCookie(token), makeSessionDataCookie(session, token)]);
19
+ const { jwt, user, permissions } = result.data;
20
+ writeCookies([makeSessionTokenCookie(jwt), makeSessionDataCookie({
21
+ user,
22
+ permissions
23
+ }, jwt)]);
30
24
  return {
31
25
  success: true,
32
- data: session.user
26
+ data: user
33
27
  };
34
28
  }
35
29
  /**
@@ -1,5 +1,6 @@
1
1
  import { panic } from "../assertions.js";
2
2
  import { sessionConfig } from "./config.js";
3
+ import { userSchema } from "./user.js";
3
4
  import { permissionsSchema } from "./permissions.js";
4
5
  import { z } from "zod";
5
6
 
@@ -11,22 +12,9 @@ function makePlatform(applicationId) {
11
12
  method: "GET",
12
13
  token
13
14
  }, z.object({
14
- user: z.object({
15
- id: z.string(),
16
- email: z.string().nullish(),
17
- fullName: z.string().nullish(),
18
- userGroupIds: z.array(z.string()).nullish()
19
- }).nullish(),
15
+ user: userSchema.nullable(),
20
16
  permissions: permissionsSchema
21
- }).transform(({ user, permissions }) => ({
22
- user: user ? {
23
- id: user.id,
24
- email: user.email ?? "",
25
- name: user.fullName ?? "",
26
- userGroupIds: user.userGroupIds ?? []
27
- } : null,
28
- permissions
29
- })));
17
+ }));
30
18
  if (result.status === 404) panic(`Application ${applicationId} not found. Check SOFTR_APPLICATION_ID.`);
31
19
  return result;
32
20
  },
@@ -35,32 +23,9 @@ function makePlatform(applicationId) {
35
23
  json: credentials
36
24
  }, z.object({
37
25
  jwt: z.string(),
38
- user: z.object({
39
- id: z.string(),
40
- email: z.string().nullish(),
41
- fullName: z.string().nullish(),
42
- userGroupIds: z.array(z.string()).nullish()
43
- }).nullish(),
26
+ user: userSchema,
44
27
  permissions: permissionsSchema
45
- }).transform(({ jwt, user, permissions }) => ({
46
- token: jwt,
47
- session: {
48
- user: user ? {
49
- id: user.id,
50
- email: user.email ?? "",
51
- name: user.fullName ?? "",
52
- userGroupIds: user.userGroupIds ?? []
53
- } : null,
54
- permissions
55
- }
56
- }))),
57
- signIn: (credentials) => request(`/v1/applications/${applicationId}/users/sign-in`, {
58
- method: "POST",
59
- json: credentials
60
- }, z.object({
61
- jwt: z.string().nullish(),
62
- authenticatorAppAction: z.string().nullish()
63
- }).nullable()),
28
+ })),
64
29
  signUp: (body) => request(`/v1/applications/${applicationId}/users/sign-up`, {
65
30
  method: "POST",
66
31
  json: body
@@ -1,5 +1,6 @@
1
1
  import { readRequestCookies, serializeCookie, writeCookies } from "../cookies.js";
2
2
  import { sessionConfig } from "./config.js";
3
+ import { userSchema } from "./user.js";
3
4
  import { permissionsSchema } from "./permissions.js";
4
5
  import { getRequestProtocol } from "@tanstack/react-start/server";
5
6
  import { z } from "zod";
@@ -69,12 +70,7 @@ function clearSession() {
69
70
  clearCookies([SESSION_TOKEN, SESSION_DATA]);
70
71
  }
71
72
  const sessionDataSchema = z.object({
72
- user: z.object({
73
- id: z.string(),
74
- email: z.string(),
75
- name: z.string(),
76
- userGroupIds: z.array(z.string())
77
- }).nullable(),
73
+ user: userSchema.nullable(),
78
74
  permissions: permissionsSchema,
79
75
  exp: z.number(),
80
76
  tb: z.string()
@@ -1,4 +1,7 @@
1
+ import { z } from "zod";
2
+
1
3
  //#region src/auth/user.d.ts
4
+
2
5
  /**
3
6
  * Declare this interface in your app to narrow every group id the SDK takes and
4
7
  * returns. Without a declaration, a group id is a plain `string`.
@@ -21,7 +24,7 @@ type SoftrUserGroupId = SoftrUserGroups extends {
21
24
  type User = {
22
25
  id: string;
23
26
  email: string;
24
- name: string;
27
+ fullName: string;
25
28
  userGroupIds: Array<SoftrUserGroupId>;
26
29
  };
27
30
  //#endregion
@@ -0,0 +1,12 @@
1
+ import { z } from "zod";
2
+
3
+ //#region src/auth/user.ts
4
+ const userSchema = z.object({
5
+ id: z.string(),
6
+ email: z.string(),
7
+ fullName: z.string(),
8
+ userGroupIds: z.array(z.string())
9
+ });
10
+
11
+ //#endregion
12
+ export { userSchema };
@@ -0,0 +1,252 @@
1
+ import { isNotFound, isRedirect } from "@tanstack/react-router";
2
+ import { createMiddleware } from "@tanstack/react-start";
3
+
4
+ //#region src/boot-error.ts
5
+ /**
6
+ * Catches errors thrown before the router renders, in request middleware or SSR
7
+ * setup, where a route's `errorComponent` cannot reach. Without it the browser
8
+ * gets a bare JSON 500 and the real message only reaches the terminal.
9
+ *
10
+ * Compose it as a dependency of the outermost request middleware, so that it
11
+ * wraps everything registered after it.
12
+ */
13
+ function bootErrorMiddleware() {
14
+ return createMiddleware({ type: "request" }).server(async ({ next, handlerType }) => {
15
+ try {
16
+ return await next();
17
+ } catch (error) {
18
+ if (handlerType === "serverFn" || error instanceof Response || isRedirect(error) || isNotFound(error)) throw error;
19
+ return new Response(errorPage(error), {
20
+ status: 500,
21
+ headers: { "content-type": "text/html; charset=utf-8" }
22
+ });
23
+ }
24
+ });
25
+ }
26
+ function errorPage(error) {
27
+ if (process.env.NODE_ENV === "production") return page("Something went wrong", "This page couldn’t be shown. Try again in a moment, and if it keeps happening, reach out to the owner of this app.", { note: "If you are the owner, you can check what happened in Studio." });
28
+ const cause = error instanceof Error ? error.cause : void 0;
29
+ const root = cause instanceof Error ? cause : error;
30
+ const message = root instanceof Error ? root.message : String(root);
31
+ const stack = root instanceof Error ? root.stack : void 0;
32
+ return page("This app crashed", "The app stopped before it could start. The technical details below show where it failed and can be copied to the agent to help fix the issue.", {
33
+ note: "These details are only shown because the app is running in development mode.",
34
+ detail: `<pre class="message">${escapeHtml(message)}</pre>`,
35
+ actions: stack ? `<button type="button" id="toggle" aria-expanded="true" aria-controls="panel">
36
+ <svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
37
+ Technical details
38
+ </button>` : "",
39
+ panel: stack ? `<div class="stack" id="panel">
40
+ <pre id="stack">${escapeHtml(stack)}</pre>
41
+ <button type="button" id="copy" aria-label="Copy stack trace">
42
+ <svg class="copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
43
+ <svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
44
+ </button>
45
+ </div>
46
+ <script>
47
+ const toggle = document.getElementById('toggle')
48
+ const panel = document.getElementById('panel')
49
+ const stack = document.getElementById('stack')
50
+
51
+ const setOpen = (open) => {
52
+ toggle.setAttribute('aria-expanded', String(open))
53
+ panel.hidden = !open
54
+ }
55
+ setOpen(false)
56
+ toggle.addEventListener('click', () => {
57
+ setOpen(toggle.getAttribute('aria-expanded') === 'false')
58
+ })
59
+
60
+ document.getElementById('copy').addEventListener('click', async (event) => {
61
+ const button = event.currentTarget
62
+ try {
63
+ await navigator.clipboard.writeText(stack.textContent)
64
+ button.dataset.copied = ''
65
+ setTimeout(() => delete button.dataset.copied, 1500)
66
+ } catch {
67
+ // Denied clipboard permission. Select it so Ctrl/Cmd+C works.
68
+ getSelection().selectAllChildren(stack)
69
+ }
70
+ })
71
+ <\/script>` : ""
72
+ });
73
+ }
74
+ function page(title, description, { note = "", detail = "", actions = "", panel = "" } = {}) {
75
+ return `<!doctype html>
76
+ <html lang="en">
77
+ <head>
78
+ <meta charset="utf-8" />
79
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
80
+ <title>${escapeHtml(title)}</title>
81
+ <style>
82
+ /* Mirrors the shadcn tokens the templates ship. Theme follows the OS
83
+ here, because a stored choice needs the app to be running. */
84
+ :root {
85
+ color-scheme: light dark;
86
+ --background: oklch(1 0 0);
87
+ --foreground: oklch(0.145 0 0);
88
+ --card: oklch(1 0 0);
89
+ --muted: oklch(0.97 0 0);
90
+ --muted-foreground: oklch(0.556 0 0);
91
+ --border: oklch(0.922 0 0);
92
+ --input: oklch(0.922 0 0);
93
+ --input-fill: oklch(1 0 0);
94
+ --input-fill-hover: oklch(0.97 0 0);
95
+ --primary: oklch(0.205 0 0);
96
+ --primary-foreground: oklch(0.985 0 0);
97
+ --destructive: oklch(0.577 0.245 27.325);
98
+ --radius: 0.625rem;
99
+ }
100
+ @media (prefers-color-scheme: dark) {
101
+ :root {
102
+ --background: oklch(0.145 0 0);
103
+ --foreground: oklch(0.985 0 0);
104
+ --card: oklch(0.205 0 0);
105
+ --muted: oklch(0.269 0 0);
106
+ --muted-foreground: oklch(0.708 0 0);
107
+ --border: oklch(1 0 0 / 10%);
108
+ --input: oklch(1 0 0 / 15%);
109
+ --input-fill: oklch(1 0 0 / 4.5%);
110
+ --input-fill-hover: oklch(1 0 0 / 7.5%);
111
+ --primary: oklch(0.922 0 0);
112
+ --primary-foreground: oklch(0.205 0 0);
113
+ --destructive: oklch(0.704 0.191 22.216);
114
+ }
115
+ }
116
+ * { box-sizing: border-box }
117
+ body {
118
+ margin: 0;
119
+ min-height: 100dvh;
120
+ display: grid;
121
+ place-items: center;
122
+ /* Without an explicit minmax the column sizes to main's max-width and
123
+ overflows narrow viewports. */
124
+ grid-template-columns: minmax(0, 1fr);
125
+ padding: 1.5rem;
126
+ background: var(--background);
127
+ color: var(--foreground);
128
+ font: 0.875rem/1.5 Inter, ui-sans-serif, system-ui, sans-serif;
129
+ overflow-wrap: anywhere;
130
+ }
131
+ main {
132
+ width: 100%;
133
+ max-width: 36rem;
134
+ display: flex;
135
+ flex-direction: column;
136
+ gap: 1.25rem;
137
+ padding: 1.5rem;
138
+ border-radius: calc(var(--radius) + 4px);
139
+ background: var(--card);
140
+ box-shadow: 0 0 0 1px color-mix(in oklch, var(--foreground) 10%, transparent);
141
+ }
142
+ .alert {
143
+ display: flex;
144
+ align-items: center;
145
+ justify-content: center;
146
+ width: 2.5rem;
147
+ height: 2.5rem;
148
+ border-radius: var(--radius);
149
+ background: color-mix(in oklch, var(--destructive) 12%, transparent);
150
+ color: var(--destructive);
151
+ }
152
+ .alert svg { width: 1.25rem; height: 1.25rem }
153
+ h1 { margin: 0; font-size: 1rem; font-weight: 500; line-height: 1.375 }
154
+ p { margin: 0.375rem 0 0; color: var(--muted-foreground) }
155
+ p.note { margin-top: 0.75rem; opacity: 0.65 }
156
+ pre {
157
+ margin: 0;
158
+ padding: 0.75rem;
159
+ border-radius: var(--radius);
160
+ background: var(--muted);
161
+ font: 0.75rem/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
162
+ max-height: 16rem;
163
+ overflow: auto;
164
+ }
165
+ pre.message { font-size: 0.8125rem; white-space: pre-wrap }
166
+ .actions { display: flex; flex-wrap: wrap; gap: 0.5rem }
167
+ .actions button {
168
+ display: inline-flex;
169
+ align-items: center;
170
+ gap: 0.375rem;
171
+ height: 2rem;
172
+ padding: 0 0.75rem;
173
+ border-radius: var(--radius);
174
+ font: inherit;
175
+ font-weight: 500;
176
+ cursor: pointer;
177
+ user-select: none;
178
+ }
179
+ .actions button svg { width: 0.875rem; height: 0.875rem }
180
+ #toggle {
181
+ padding-left: 0.5rem;
182
+ border: 1px solid var(--input);
183
+ background: var(--input-fill);
184
+ color: inherit;
185
+ }
186
+ #toggle:hover { background: var(--input-fill-hover) }
187
+ .chevron { transition: transform 150ms }
188
+ #toggle[aria-expanded='true'] .chevron { transform: rotate(90deg) }
189
+ .stack { position: relative }
190
+ .stack pre { padding-right: 2.75rem }
191
+ #copy {
192
+ position: absolute;
193
+ top: 0.375rem;
194
+ right: 0.375rem;
195
+ display: inline-flex;
196
+ align-items: center;
197
+ justify-content: center;
198
+ width: 1.75rem;
199
+ height: 1.75rem;
200
+ padding: 0;
201
+ /* Opaque, so the stack trace underneath never shows through the icon. */
202
+ border: 1px solid var(--input);
203
+ border-radius: calc(var(--radius) - 2px);
204
+ background: var(--card);
205
+ color: var(--muted-foreground);
206
+ cursor: pointer;
207
+ }
208
+ #copy:hover {
209
+ background: color-mix(in oklch, var(--card), var(--foreground) 6%);
210
+ color: var(--foreground);
211
+ }
212
+ #copy svg { width: 0.875rem; height: 0.875rem }
213
+ #copy .check, #copy[data-copied] .copy { display: none }
214
+ #copy[data-copied] .check { display: block }
215
+ .retry {
216
+ border: 1px solid transparent;
217
+ background: var(--primary);
218
+ color: var(--primary-foreground);
219
+ }
220
+ /* Mixed with the card rather than faded, so it stays opaque. */
221
+ .retry:hover { background: color-mix(in oklch, var(--primary) 80%, var(--card)) }
222
+ </style>
223
+ </head>
224
+ <body>
225
+ <main>
226
+ <div class="alert">
227
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
228
+ </div>
229
+ <div>
230
+ <h1>${escapeHtml(title)}</h1>
231
+ <p>${escapeHtml(description)}</p>
232
+ ${note ? `<p class="note">${escapeHtml(note)}</p>` : ""}
233
+ </div>
234
+ ${detail}
235
+ <div class="actions">
236
+ <button type="button" class="retry" onclick="location.reload()">
237
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
238
+ Try again
239
+ </button>
240
+ ${actions}
241
+ </div>
242
+ ${panel}
243
+ </main>
244
+ </body>
245
+ </html>`;
246
+ }
247
+ function escapeHtml(value) {
248
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
249
+ }
250
+
251
+ //#endregion
252
+ export { bootErrorMiddleware };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: core
12
12
  library: softr-sdk
13
- library_version: 0.0.20
13
+ library_version: 0.0.21
14
14
  ---
15
15
 
16
16
  # Softr SDK core
@@ -116,11 +116,11 @@ type SoftrContext = {
116
116
  type User = {
117
117
  id: string
118
118
  email: string
119
- name: string
119
+ fullName: string
120
120
  userGroupIds: Array<SoftrUserGroupId> // plain string unless you declare your ids
121
121
  }
122
122
  ```
123
123
 
124
124
  ## Version
125
125
 
126
- Targets `@softr/sdk` v0.0.20.
126
+ Targets `@softr/sdk` v0.0.21.
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.20
13
+ library_version: 0.0.21
14
14
  ---
15
15
 
16
16
  This skill builds on softr-sdk. Read it first for the entrypoints and the client/server boundary.
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.20
13
+ library_version: 0.0.21
14
14
  ---
15
15
 
16
16
  This skill builds on softr-sdk. Read it first for the entrypoints and the wiring that puts a visitor on route context.
@@ -46,7 +46,7 @@ import { useCurrentUser } from '@softr/sdk'
46
46
 
47
47
  function Header() {
48
48
  const user = useCurrentUser()
49
- return <p>{user ? `Hello, ${user.name}` : 'Please sign in'}</p>
49
+ return <p>{user ? `Hello, ${user.fullName}` : 'Please sign in'}</p>
50
50
  }
51
51
  ```
52
52
 
@@ -68,11 +68,14 @@ A `guard()` in `beforeLoad` guarantees a visitor at runtime, but it does not cha
68
68
  ```ts
69
69
  import { createServerFn } from '@tanstack/react-start'
70
70
 
71
+ // The datasource id from the table's registration on the application — not the table id.
72
+ const INVOICES = 'e4b7c2a1-9d3f-4e68-b05a-7f1c6d2e8a93'
73
+
71
74
  const getMyInvoices = createServerFn().handler(async ({ context }) => {
72
75
  if (!context.user) throw new Error('Not signed in')
73
76
 
74
- return await ds.list('ds_invoices', {
75
- select: q.select({ owner: 'fld_owner', amount: 'fld_amount' }),
77
+ return await ds.list(INVOICES, {
78
+ select: q.select({ owner: 'Gq5wNt8rJy3Hb1', amount: 'Vk9cXm2pSz6Ld4' }),
76
79
  where: q.text('owner').is(context.user.id),
77
80
  })
78
81
  })
@@ -85,13 +88,15 @@ const getMyInvoices = createServerFn().handler(async ({ context }) => {
85
88
  ```tsx
86
89
  import { usePermissions } from '@softr/sdk'
87
90
 
91
+ const INVOICES = 'e4b7c2a1-9d3f-4e68-b05a-7f1c6d2e8a93' // the registered datasource's id
92
+
88
93
  function NewInvoiceButton() {
89
- const { create } = usePermissions('ds_invoices')
94
+ const { create } = usePermissions(INVOICES)
90
95
  return create ? <button>New invoice</button> : null
91
96
  }
92
97
  ```
93
98
 
94
- `usePermissions(datasourceId)` returns `{ read, create, update, delete }` for one datasource. It hides a control. It does not protect what the control does.
99
+ `usePermissions(datasourceId)` returns `{ read, create, update, delete }` for one datasource. The id is the opaque UUID from the table's registration on the application, the same one `ds.*` takes — not the table id, and never built from the table's name. It hides a control. It does not protect what the control does.
95
100
 
96
101
  Pass an id the visitor's session has no entry for and it throws while rendering, so a typo takes the page down rather than returning four falses.
97
102
 
@@ -8,7 +8,7 @@ description: >-
8
8
  metadata:
9
9
  type: sub-skill
10
10
  library: softr-sdk
11
- library_version: 0.0.20
11
+ library_version: 0.0.21
12
12
  ---
13
13
 
14
14
  This skill builds on softr-sdk. Read it first for the entrypoints and the client/server boundary.
@@ -24,10 +24,16 @@ import { createServerFn } from '@tanstack/react-start'
24
24
  import { z } from 'zod'
25
25
  import { ds } from '@softr/sdk/server'
26
26
  import { q } from '@softr/sdk'
27
+
28
+ // The datasource id Softr assigned when the table was registered on the application.
29
+ // Copy it from the registration (datasource.datasourceId); it is not the table id.
30
+ const PROJECTS = '8ffb4814-b028-4496-b679-7face2167ebc'
27
31
  ```
28
32
 
29
33
  Pass a zod schema straight to `.validator`. Start accepts it and types `data` from it.
30
34
 
35
+ Every `ds` call names its datasource by that id: an opaque UUID from the table's registration on the application (Softr Studio's Data tab, or the `register_application_data_source` tool when an agent does it). It is never derived from the table's name. Field ids are opaque too (`fZk3Qm9pLw2Xa1`, not `fld_<name>`) and come from the table's schema.
36
+
31
37
  ## Core Patterns
32
38
 
33
39
  ### Create a record
@@ -38,9 +44,9 @@ const NewProject = z.object({ name: z.string(), status: z.string() })
38
44
  const createProject = createServerFn({ method: 'POST' })
39
45
  .validator(NewProject)
40
46
  .handler(async ({ data }) =>
41
- ds.create('ds_projects', {
47
+ ds.create(PROJECTS, {
42
48
  data,
43
- select: q.select({ name: 'fld_name', status: 'fld_status' }),
49
+ select: q.select({ name: 'fZk3Qm9pLw2Xa1', status: 'Ru7bVx2cNq0Ed9' }),
44
50
  }),
45
51
  )
46
52
  ```
@@ -59,10 +65,10 @@ const Rename = z.object({ id: z.string(), name: z.string() })
59
65
  const renameProject = createServerFn({ method: 'POST' })
60
66
  .validator(Rename)
61
67
  .handler(async ({ data }) =>
62
- ds.update('ds_projects', {
68
+ ds.update(PROJECTS, {
63
69
  id: data.id,
64
70
  data: { name: data.name },
65
- select: q.select({ name: 'fld_name' }),
71
+ select: q.select({ name: 'fZk3Qm9pLw2Xa1' }),
66
72
  }),
67
73
  )
68
74
  ```
@@ -78,7 +84,7 @@ const RecordId = z.object({ id: z.string() })
78
84
 
79
85
  const deleteProject = createServerFn({ method: 'POST' })
80
86
  .validator(RecordId)
81
- .handler(async ({ data }) => ds.delete('ds_projects', data.id))
87
+ .handler(async ({ data }) => ds.delete(PROJECTS, data.id))
82
88
  ```
83
89
 
84
90
  **`delete` takes the record id as a positional argument**, unlike `create` and `update`, which take an options object. It resolves to nothing.
@@ -95,6 +101,8 @@ Stamping a record with who created it, or scoping an update to the person making
95
101
 
96
102
  ## Failure Modes
97
103
 
104
+ **A datasource id built from a name.** `'ds_projects'`, the table id, or the table's name glued to a uuid are not datasource ids. The id is the opaque UUID assigned when the table was registered on the application; copy it from there. Anything else fails at request time as not found or forbidden.
105
+
98
106
  **A write that sends `fields`.** The option is `data`. Only typecheck catches this, so it survives any run that does not typecheck.
99
107
 
100
108
  **A key missing from `select`.** The key is not translated, so the field it was meant for is not the field written. Check the map covers every key rather than trusting the call to complain.
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.20
13
+ library_version: 0.0.21
14
14
  ---
15
15
 
16
16
  This skill builds on softr-sdk. Read it first for the entrypoints and the client/server boundary.
@@ -25,10 +25,16 @@ This skill builds on softr-sdk. Read it first for the entrypoints and the client
25
25
  import { createServerFn } from '@tanstack/react-start'
26
26
  import { ds } from '@softr/sdk/server'
27
27
  import { q } from '@softr/sdk'
28
+
29
+ // The datasource id Softr assigned when the table was registered on the application.
30
+ // Copy it from the registration (datasource.datasourceId); it is not the table id.
31
+ const POSTS = '8ffb4814-b028-4496-b679-7face2167ebc'
28
32
  ```
29
33
 
30
34
  `ds` is server-only and lives on `@softr/sdk/server`. `q` builds queries and nothing more, so it ships on the root entrypoint and can be imported anywhere.
31
35
 
36
+ Every `ds` call names its datasource by that id: an opaque UUID from the table's registration on the application (Softr Studio's Data tab, or the `register_application_data_source` tool when an agent does it). It is not the table id, and it is never derived from the table's name — `'ds_posts'` or a name glued to a uuid fails at request time as not found or forbidden.
37
+
32
38
  A record comes back as `{ id: string; fields: Record<string, unknown> }`. When you write a TypeScript type for the fields, the shape of each value depends on the Softr field type — see [references/field-types.md](./references/field-types.md) for the full mapping.
33
39
 
34
40
  ## Typing record fields
@@ -53,8 +59,10 @@ Cast at the `ds.list` call using the `ListResult` generic so the type flows thro
53
59
  ```ts
54
60
  import type { ListResult, SoftrRecord } from '@softr/sdk'
55
61
 
62
+ const PRODUCTS = '3c1e8a52-6f0d-4b7e-9a21-5d84c0f7b2e6' // from the table's registration
63
+
56
64
  const fetchProducts = createServerFn().handler(() =>
57
- ds.list('products', { select, limit: 100 }) as Promise<ListResult<Product>>
65
+ ds.list(PRODUCTS, { select, limit: 100 }) as Promise<ListResult<Product>>
58
66
  )
59
67
  ```
60
68
 
@@ -66,12 +74,12 @@ const fetchProducts = createServerFn().handler(() =>
66
74
 
67
75
  ```ts
68
76
  const listPublishedPosts = createServerFn().handler(async () => {
69
- return await ds.list('ds_posts', {
77
+ return await ds.list(POSTS, {
70
78
  select: q.select({
71
- title: 'fld_title',
72
- author: 'fld_author',
73
- published: 'fld_published',
74
- publishedAt: 'fld_published_at',
79
+ title: 'fZk3Qm9pLw2Xa1',
80
+ author: 'mB4kQ8yTn1sWc7',
81
+ published: 'Ru7bVx2cNq0Ed9',
82
+ publishedAt: 'Xp2dLh6vKz9Fa3',
75
83
  }),
76
84
  where: q.boolean('published').is(true),
77
85
  orderBy: q.desc('publishedAt'),
@@ -80,20 +88,20 @@ const listPublishedPosts = createServerFn().handler(async () => {
80
88
  })
81
89
  ```
82
90
 
83
- Returns `{ records, total, nextOffset }`. `ds.list('ds_posts')` on its own returns the first 20 records with their raw field ids. `limit` defaults to 20 and is clamped to 100. A larger number is silently reduced, so more than a hundred records means paginating.
91
+ Returns `{ records, total, nextOffset }`. `ds.list(POSTS)` on its own returns the first 20 records with their raw field ids. `limit` defaults to 20 and is clamped to 100. A larger number is silently reduced, so more than a hundred records means paginating.
84
92
 
85
93
  **Filtering or sorting requires a `select`, and it has to cover every field you name.** `where` and `orderBy` speak aliases, so without a map to translate through they throw at request time. That is why `published` is in the map above even though the table never displays it.
86
94
 
87
95
  ### Name the fields with `select`
88
96
 
89
- Softr field ids are opaque (`fld_a1b2c3`). `q.select` maps your own names onto them, and those names are what the rest of the call speaks:
97
+ Softr field ids are opaque strings (`fZk3Qm9pLw2Xa1`), copied from the table's schema. `q.select` maps your own names onto them, and those names are what the rest of the call speaks:
90
98
 
91
99
  ```ts
92
- const select = q.select({ title: 'fld_a1b2c3', views: 'fld_d4e5f6' })
100
+ const select = q.select({ title: 'fZk3Qm9pLw2Xa1', views: 'Ru7bVx2cNq0Ed9' })
93
101
 
94
- await ds.list('ds_posts', {
102
+ await ds.list(POSTS, {
95
103
  select,
96
- where: q.number('views').gt(100), // alias, not fld_d4e5f6
104
+ where: q.number('views').gt(100), // alias, not Ru7bVx2cNq0Ed9
97
105
  orderBy: q.asc('title'), // alias here too
98
106
  })
99
107
  // records[0].fields is { title: ..., views: ... }
@@ -104,7 +112,7 @@ Once `select` is present it is the whole vocabulary. The returned record carries
104
112
  ### Get one record
105
113
 
106
114
  ```ts
107
- const post = await ds.get('ds_posts', { id: postId })
115
+ const post = await ds.get(POSTS, { id: postId })
108
116
  if (post === null) throw notFound()
109
117
  ```
110
118
 
@@ -130,9 +138,9 @@ Five field types, `q.text`, `q.number`, `q.boolean`, `q.date` and `q.array`, com
130
138
  ### Paginate
131
139
 
132
140
  ```ts
133
- const first = await ds.list('ds_posts', { limit: 50 })
141
+ const first = await ds.list(POSTS, { limit: 50 })
134
142
  if (first.nextOffset !== null) {
135
- const second = await ds.list('ds_posts', { limit: 50, offset: first.nextOffset })
143
+ const second = await ds.list(POSTS, { limit: 50, offset: first.nextOffset })
136
144
  }
137
145
  ```
138
146
 
@@ -141,11 +149,13 @@ if (first.nextOffset !== null) {
141
149
  ### Scope the read to the visitor
142
150
 
143
151
  ```ts
152
+ const INVOICES = 'e4b7c2a1-9d3f-4e68-b05a-7f1c6d2e8a93' // from the table's registration
153
+
144
154
  const listMyInvoices = createServerFn().handler(async ({ context }) => {
145
155
  if (!context.user) throw new Error('Not signed in')
146
156
 
147
- return await ds.list('ds_invoices', {
148
- select: q.select({ owner: 'fld_owner', amount: 'fld_amount' }),
157
+ return await ds.list(INVOICES, {
158
+ select: q.select({ owner: 'Gq5wNt8rJy3Hb1', amount: 'Vk9cXm2pSz6Ld4' }),
149
159
  where: q.text('owner').is(context.user.id),
150
160
  })
151
161
  })
@@ -10,7 +10,7 @@ import { q } from '@softr/sdk'
10
10
 
11
11
  ```ts
12
12
  await ds.list(datasourceId, {
13
- select: q.select({ alias: 'fld_id', ... }), // optional field map
13
+ select: q.select({ alias: '<field id>', ... }), // optional field map
14
14
  where: <one filter, or q.and/q.or of filters>,
15
15
  orderBy: q.asc('alias') | [q.desc('a'), q.asc('b')],
16
16
  limit: number, // default 20, clamped down to 100
@@ -123,10 +123,10 @@ Sorts are applied in array order.
123
123
  ## Field maps
124
124
 
125
125
  ```ts
126
- const select = q.select({ title: 'fld_a1b2c3', views: 'fld_d4e5f6' })
126
+ const select = q.select({ title: 'fZk3Qm9pLw2Xa1', views: 'Ru7bVx2cNq0Ed9' })
127
127
  ```
128
128
 
129
- `q.select` maps names you choose onto Softr field ids. Two rules follow from that:
129
+ `q.select` maps names you choose onto Softr field ids — opaque strings copied from the table's schema. Two rules follow from that:
130
130
 
131
131
  1. The returned record carries exactly the keys in the map. A field left out is not returned, and no error is raised.
132
132
  2. `where` and `orderBy` name aliases, and every one of them must be in the map. A name that is not throws `Could not find an alias for subject "..."` at request time, and so does a `where` or `orderBy` passed with no `select` at all.
@@ -12,7 +12,7 @@ description: >-
12
12
  metadata:
13
13
  type: sub-skill
14
14
  library: softr-sdk
15
- library_version: 0.0.20
15
+ library_version: 0.0.21
16
16
  ---
17
17
 
18
18
  This skill builds on softr-sdk. Read it first for the entrypoints and the wiring that puts a visitor on route context.