@softr/sdk 0.0.19 → 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.
- package/dist/auth/isomorphic.d.ts +2 -2
- package/dist/auth/isomorphic.js +3 -2
- package/dist/auth/methods/password.js +8 -14
- package/dist/auth/platform.js +5 -40
- package/dist/auth/session.js +2 -6
- package/dist/auth/user.d.ts +4 -1
- package/dist/auth/user.js +12 -0
- package/dist/boot-error.js +252 -0
- package/dist/index.d.ts +2 -2
- package/dist/types.d.ts +12 -9
- package/package.json +1 -1
- package/skills/softr-sdk/SKILL.md +3 -3
- package/skills/softr-sdk/auth/SKILL.md +1 -1
- package/skills/softr-sdk/current-user/SKILL.md +11 -6
- package/skills/softr-sdk/data-mutations/SKILL.md +14 -6
- package/skills/softr-sdk/reading-records/SKILL.md +57 -17
- package/skills/softr-sdk/reading-records/references/field-types.md +70 -0
- package/skills/softr-sdk/reading-records/references/query-builder.md +3 -3
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +1 -1
|
@@ -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.
|
|
51
|
+
* return <p>{user ? `Hello, ${user.fullName}` : "Please sign in"}</p>
|
|
52
52
|
* }
|
|
53
53
|
*/
|
|
54
54
|
declare function useCurrentUser(): User | null;
|
package/dist/auth/isomorphic.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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:
|
|
26
|
+
data: user
|
|
33
27
|
};
|
|
34
28
|
}
|
|
35
29
|
/**
|
package/dist/auth/platform.js
CHANGED
|
@@ -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:
|
|
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
|
-
})
|
|
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:
|
|
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
|
-
})
|
|
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
|
package/dist/auth/session.js
CHANGED
|
@@ -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:
|
|
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()
|
package/dist/auth/user.d.ts
CHANGED
|
@@ -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
|
-
|
|
27
|
+
fullName: string;
|
|
25
28
|
userGroupIds: Array<SoftrUserGroupId>;
|
|
26
29
|
};
|
|
27
30
|
//#endregion
|
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
//#endregion
|
|
252
|
+
export { bootErrorMiddleware };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { q } from "./core/query.js";
|
|
2
2
|
import { SoftrUserGroupId, SoftrUserGroups, User } from "./auth/user.js";
|
|
3
|
-
import { AggregationFunction, ChartDataOptions, ChartDataResult, CreateOptions, DatasourcePermissions, GetOptions, ListOptions, ListResult, MetricOptions, MetricResult, SoftrMe, SoftrPermissions, SoftrRecord, SoftrUser, UpdateOptions } from "./types.js";
|
|
3
|
+
import { AggregationFunction, ChartDataOptions, ChartDataResult, CreateOptions, DatasourcePermissions, GetOptions, JsonValue, ListOptions, ListResult, MetricOptions, MetricResult, SoftrMe, SoftrPermissions, SoftrRecord, SoftrUser, UpdateOptions } from "./types.js";
|
|
4
4
|
import { Permissions } from "./auth/permissions.js";
|
|
5
5
|
import { SoftrContext } from "./auth/context.js";
|
|
6
6
|
import { AccessBehaviour, AccessCheck, AccessError, AccessReason, RouteVisibility, VisibilityHandle, defineVisibility, isAccessError, isAlreadySignedInError, 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
|
-
export { type AccessBehaviour, type AccessCheck, type AccessError, type AccessReason, type AggregationFunction, type ChartDataOptions, type ChartDataResult, type CreateOptions, type DatasourcePermissions, type GetOptions, type ListOptions, type ListResult, type MetricOptions, type MetricResult, type Permissions, type RouteVisibility, type SoftrContext, type SoftrMe, type SoftrPermissions, type SoftrRecord, type SoftrUser, type SoftrUserGroupId, type SoftrUserGroups, type UpdateOptions, type User, type VisibilityHandle, defineVisibility, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, q, softrAuthMiddleware, useCurrentUser, usePermissions, useUserTimezone };
|
|
9
|
+
export { type AccessBehaviour, type AccessCheck, type AccessError, type AccessReason, type AggregationFunction, type ChartDataOptions, type ChartDataResult, type CreateOptions, type DatasourcePermissions, type GetOptions, type JsonValue, type ListOptions, type ListResult, type MetricOptions, type MetricResult, type Permissions, type RouteVisibility, type SoftrContext, type SoftrMe, type SoftrPermissions, type SoftrRecord, type SoftrUser, type SoftrUserGroupId, type SoftrUserGroups, type UpdateOptions, type User, type VisibilityHandle, defineVisibility, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, q, softrAuthMiddleware, useCurrentUser, usePermissions, useUserTimezone };
|
package/dist/types.d.ts
CHANGED
|
@@ -53,16 +53,19 @@ interface ListOptions {
|
|
|
53
53
|
/** Override the timezone for this request. */
|
|
54
54
|
timeZone?: string;
|
|
55
55
|
}
|
|
56
|
-
|
|
56
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
57
|
+
[key: string]: JsonValue;
|
|
58
|
+
};
|
|
59
|
+
interface SoftrRecord<T extends Record<string, JsonValue> = Record<string, JsonValue>> {
|
|
60
|
+
id: string;
|
|
61
|
+
fields: T;
|
|
62
|
+
}
|
|
63
|
+
interface ListResult<T extends SoftrRecord = SoftrRecord> {
|
|
57
64
|
records: T[];
|
|
58
65
|
total: number;
|
|
59
66
|
/** Offset to pass as `offset` in the next call, or `null` when there are no more pages. */
|
|
60
67
|
nextOffset: number | null;
|
|
61
68
|
}
|
|
62
|
-
interface SoftrRecord {
|
|
63
|
-
id: string;
|
|
64
|
-
fields: Record<string, unknown>;
|
|
65
|
-
}
|
|
66
69
|
type AggregationFunction = 'SUM' | 'AVG' | 'MAX' | 'MIN' | 'COUNT' | 'DISTINCT';
|
|
67
70
|
interface MetricOptions {
|
|
68
71
|
select?: Record<string, string>;
|
|
@@ -91,7 +94,7 @@ interface ChartDataOptions {
|
|
|
91
94
|
timeZone?: string;
|
|
92
95
|
}
|
|
93
96
|
interface ChartDataResult {
|
|
94
|
-
data: Record<string,
|
|
97
|
+
data: Record<string, JsonValue>[];
|
|
95
98
|
executionTimestamp: Date;
|
|
96
99
|
}
|
|
97
100
|
interface CreateOptions {
|
|
@@ -100,7 +103,7 @@ interface CreateOptions {
|
|
|
100
103
|
* When provided, `data` keys are interpreted as aliases.
|
|
101
104
|
*/
|
|
102
105
|
select?: Record<string, string>;
|
|
103
|
-
data: Record<string,
|
|
106
|
+
data: Record<string, JsonValue>;
|
|
104
107
|
}
|
|
105
108
|
interface UpdateOptions {
|
|
106
109
|
id: string;
|
|
@@ -109,7 +112,7 @@ interface UpdateOptions {
|
|
|
109
112
|
* When provided, `data` keys are interpreted as aliases.
|
|
110
113
|
*/
|
|
111
114
|
select?: Record<string, string>;
|
|
112
|
-
data: Record<string,
|
|
115
|
+
data: Record<string, JsonValue>;
|
|
113
116
|
}
|
|
114
117
|
//#endregion
|
|
115
|
-
export { AggregationFunction, ChartDataOptions, ChartDataResult, CreateOptions, DatasourcePermissions, GetOptions, ListOptions, ListResult, MetricOptions, MetricResult, SoftrMe, SoftrPermissions, SoftrRecord, SoftrUser, UpdateOptions };
|
|
118
|
+
export { AggregationFunction, ChartDataOptions, ChartDataResult, CreateOptions, DatasourcePermissions, GetOptions, JsonValue, ListOptions, ListResult, MetricOptions, MetricResult, SoftrMe, SoftrPermissions, SoftrRecord, SoftrUser, UpdateOptions };
|
package/package.json
CHANGED
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: core
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
75
|
-
select: q.select({ owner: '
|
|
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(
|
|
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.
|
|
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(
|
|
47
|
+
ds.create(PROJECTS, {
|
|
42
48
|
data,
|
|
43
|
-
select: q.select({ name: '
|
|
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(
|
|
68
|
+
ds.update(PROJECTS, {
|
|
63
69
|
id: data.id,
|
|
64
70
|
data: { name: data.name },
|
|
65
|
-
select: q.select({ 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(
|
|
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.
|
|
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,11 +25,48 @@ 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
|
|
|
32
|
-
|
|
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
|
+
|
|
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.
|
|
39
|
+
|
|
40
|
+
## Typing record fields
|
|
41
|
+
|
|
42
|
+
Declare a local type for the records you fetch using `SoftrRecord<Fields>`. Use the shapes from the field-types reference, not guesses from the field name. The most common mistake is treating SELECT and LINKED_RECORD fields as strings — they are objects:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import type { SoftrRecord } from '@softr/sdk'
|
|
46
|
+
|
|
47
|
+
type Product = SoftrRecord<{
|
|
48
|
+
name: string // SINGLE_LINE_TEXT
|
|
49
|
+
price: number | null // CURRENCY
|
|
50
|
+
category: { id: string; label: string } // SELECT — not a string
|
|
51
|
+
supplier: { id: string; label: string } // LINKED_RECORD — not a string
|
|
52
|
+
}>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`SoftrRecord<Fields>` is shorthand for `{ id: string; fields: Fields }` — the `Fields` type parameter constrains every value to `JsonValue`, so TypeScript rejects non-serializable types like `Date` objects.
|
|
56
|
+
|
|
57
|
+
Cast at the `ds.list` call using the `ListResult` generic so the type flows through the loader automatically:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import type { ListResult, SoftrRecord } from '@softr/sdk'
|
|
61
|
+
|
|
62
|
+
const PRODUCTS = '3c1e8a52-6f0d-4b7e-9a21-5d84c0f7b2e6' // from the table's registration
|
|
63
|
+
|
|
64
|
+
const fetchProducts = createServerFn().handler(() =>
|
|
65
|
+
ds.list(PRODUCTS, { select, limit: 100 }) as Promise<ListResult<Product>>
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`loaderData` is then already typed as `ListResult<Product>` — no second cast needed in the component. All the shapes in [references/field-types.md](./references/field-types.md) satisfy the `JsonValue` constraint.
|
|
33
70
|
|
|
34
71
|
## Core Patterns
|
|
35
72
|
|
|
@@ -37,12 +74,12 @@ A record comes back as `{ id: string; fields: Record<string, unknown> }`.
|
|
|
37
74
|
|
|
38
75
|
```ts
|
|
39
76
|
const listPublishedPosts = createServerFn().handler(async () => {
|
|
40
|
-
return await ds.list(
|
|
77
|
+
return await ds.list(POSTS, {
|
|
41
78
|
select: q.select({
|
|
42
|
-
title: '
|
|
43
|
-
author: '
|
|
44
|
-
published: '
|
|
45
|
-
publishedAt: '
|
|
79
|
+
title: 'fZk3Qm9pLw2Xa1',
|
|
80
|
+
author: 'mB4kQ8yTn1sWc7',
|
|
81
|
+
published: 'Ru7bVx2cNq0Ed9',
|
|
82
|
+
publishedAt: 'Xp2dLh6vKz9Fa3',
|
|
46
83
|
}),
|
|
47
84
|
where: q.boolean('published').is(true),
|
|
48
85
|
orderBy: q.desc('publishedAt'),
|
|
@@ -51,20 +88,20 @@ const listPublishedPosts = createServerFn().handler(async () => {
|
|
|
51
88
|
})
|
|
52
89
|
```
|
|
53
90
|
|
|
54
|
-
Returns `{ records, total, nextOffset }`. `ds.list(
|
|
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.
|
|
55
92
|
|
|
56
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.
|
|
57
94
|
|
|
58
95
|
### Name the fields with `select`
|
|
59
96
|
|
|
60
|
-
Softr field ids are opaque (`
|
|
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:
|
|
61
98
|
|
|
62
99
|
```ts
|
|
63
|
-
const select = q.select({ title: '
|
|
100
|
+
const select = q.select({ title: 'fZk3Qm9pLw2Xa1', views: 'Ru7bVx2cNq0Ed9' })
|
|
64
101
|
|
|
65
|
-
await ds.list(
|
|
102
|
+
await ds.list(POSTS, {
|
|
66
103
|
select,
|
|
67
|
-
where: q.number('views').gt(100), // alias, not
|
|
104
|
+
where: q.number('views').gt(100), // alias, not Ru7bVx2cNq0Ed9
|
|
68
105
|
orderBy: q.asc('title'), // alias here too
|
|
69
106
|
})
|
|
70
107
|
// records[0].fields is { title: ..., views: ... }
|
|
@@ -75,7 +112,7 @@ Once `select` is present it is the whole vocabulary. The returned record carries
|
|
|
75
112
|
### Get one record
|
|
76
113
|
|
|
77
114
|
```ts
|
|
78
|
-
const post = await ds.get(
|
|
115
|
+
const post = await ds.get(POSTS, { id: postId })
|
|
79
116
|
if (post === null) throw notFound()
|
|
80
117
|
```
|
|
81
118
|
|
|
@@ -101,9 +138,9 @@ Five field types, `q.text`, `q.number`, `q.boolean`, `q.date` and `q.array`, com
|
|
|
101
138
|
### Paginate
|
|
102
139
|
|
|
103
140
|
```ts
|
|
104
|
-
const first = await ds.list(
|
|
141
|
+
const first = await ds.list(POSTS, { limit: 50 })
|
|
105
142
|
if (first.nextOffset !== null) {
|
|
106
|
-
const second = await ds.list(
|
|
143
|
+
const second = await ds.list(POSTS, { limit: 50, offset: first.nextOffset })
|
|
107
144
|
}
|
|
108
145
|
```
|
|
109
146
|
|
|
@@ -112,11 +149,13 @@ if (first.nextOffset !== null) {
|
|
|
112
149
|
### Scope the read to the visitor
|
|
113
150
|
|
|
114
151
|
```ts
|
|
152
|
+
const INVOICES = 'e4b7c2a1-9d3f-4e68-b05a-7f1c6d2e8a93' // from the table's registration
|
|
153
|
+
|
|
115
154
|
const listMyInvoices = createServerFn().handler(async ({ context }) => {
|
|
116
155
|
if (!context.user) throw new Error('Not signed in')
|
|
117
156
|
|
|
118
|
-
return await ds.list(
|
|
119
|
-
select: q.select({ owner: '
|
|
157
|
+
return await ds.list(INVOICES, {
|
|
158
|
+
select: q.select({ owner: 'Gq5wNt8rJy3Hb1', amount: 'Vk9cXm2pSz6Ld4' }),
|
|
120
159
|
where: q.text('owner').is(context.user.id),
|
|
121
160
|
})
|
|
122
161
|
})
|
|
@@ -130,6 +169,7 @@ A read the visitor is not allowed to make throws rather than coming back empty,
|
|
|
130
169
|
|
|
131
170
|
## References
|
|
132
171
|
|
|
172
|
+
- [references/field-types.md](./references/field-types.md): TypeScript shape for every Softr field type
|
|
133
173
|
- [references/query-builder.md](./references/query-builder.md): every `q` field type and operator
|
|
134
174
|
- [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor off `context` to scope a query
|
|
135
175
|
- [routes-and-visibility/SKILL.md](../routes-and-visibility/SKILL.md): deciding who sees the page the records land on
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Softr field types — TypeScript shapes
|
|
2
|
+
|
|
3
|
+
When you write a TypeScript type for a record's fields, use these shapes. The field's **type** (shown in the datasource metadata or described by the user) determines the shape, not the field's name.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
// Simple scalar types
|
|
7
|
+
SINGLE_LINE_TEXT: string
|
|
8
|
+
LONG_TEXT: string
|
|
9
|
+
EMAIL: string
|
|
10
|
+
PHONE: string
|
|
11
|
+
URL: string
|
|
12
|
+
ADDRESS: string
|
|
13
|
+
LOCATION: string
|
|
14
|
+
BUTTON: string
|
|
15
|
+
RECORD_ID: string
|
|
16
|
+
DATETIME: string // ISO timestamp string
|
|
17
|
+
DATE: string // ISO date string
|
|
18
|
+
TIME: string
|
|
19
|
+
CREATED_AT: string
|
|
20
|
+
UPDATED_AT: string
|
|
21
|
+
CHECKBOX: string | boolean
|
|
22
|
+
AUTONUMBER: number | null
|
|
23
|
+
NUMBER: number | null
|
|
24
|
+
PERCENT: number | null
|
|
25
|
+
CURRENCY: number | null
|
|
26
|
+
PROGRESS: number | null
|
|
27
|
+
DURATION: number | null
|
|
28
|
+
COUNT: number | null
|
|
29
|
+
RATING: string | number | null
|
|
30
|
+
FORMULA: string | number
|
|
31
|
+
|
|
32
|
+
// Object types — these are NOT plain strings
|
|
33
|
+
SELECT: { id: string; label: string }
|
|
34
|
+
LINKED_RECORD: { id: string; label: string }
|
|
35
|
+
|
|
36
|
+
DATE_RANGE: { from: string; to: string }
|
|
37
|
+
|
|
38
|
+
ATTACHMENT: {
|
|
39
|
+
filename: string | null | undefined
|
|
40
|
+
id: string | null | undefined
|
|
41
|
+
type: string | null | undefined
|
|
42
|
+
url: string
|
|
43
|
+
thumbnails?: Array<{ size: 'small' | 'medium' | 'large'; url: string }>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
USER: { avatarUrl: string | null; id: string; name: string; email: string }
|
|
47
|
+
|
|
48
|
+
CREATED_BY: string | string[] | { avatarUrl: string | null; id: string; name: string; email: string }
|
|
49
|
+
UPDATED_BY: string | string[] | { avatarUrl: string | null; id: string; name: string; email: string }
|
|
50
|
+
|
|
51
|
+
// These depend on what the linked field/aggregation resolves to
|
|
52
|
+
LOOKUP: unknown
|
|
53
|
+
ROLLUP: unknown
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## The non-obvious ones
|
|
57
|
+
|
|
58
|
+
**SELECT and LINKED_RECORD** are the most commonly mistyped. They are never a plain string — always `{ id: string; label: string }`. To display the value, use `.label`. To filter or compare, use `.id`.
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
// Correct
|
|
62
|
+
<td>{record.fields.status?.label}</td>
|
|
63
|
+
|
|
64
|
+
// Wrong — status is not a string
|
|
65
|
+
<td>{record.fields.status}</td>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**CHECKBOX** can be either a string (`'true'` / `'false'`) or a boolean depending on the datasource. Type it as `string | boolean` and coerce when needed: `!!record.fields.active`.
|
|
69
|
+
|
|
70
|
+
**CREATED_BY / UPDATED_BY** can be a plain id string, an array of ids, or a full user object. Narrow before using.
|
|
@@ -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: '
|
|
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: '
|
|
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.
|