@softr/sdk 0.0.78-alpha.1 → 0.0.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/action.d.ts +1 -34
  2. package/dist/action.js +1 -12
  3. package/dist/assertions.js +1 -1
  4. package/dist/auth/methods/profile.d.ts +5 -5
  5. package/dist/auth/methods/profile.js +2 -2
  6. package/dist/auth/platform.d.ts +0 -1
  7. package/dist/auth/platform.js +36 -14
  8. package/dist/auth/session.js +1 -1
  9. package/dist/auth/user.d.ts +8 -1
  10. package/dist/auth/user.js +2 -1
  11. package/dist/cli.js +38 -12
  12. package/dist/codegen/datasource-docs.js +92 -1
  13. package/dist/codegen/datasource-types.js +16 -1
  14. package/dist/codegen/fetch-metadata.js +27 -11
  15. package/dist/codegen/softr-env.js +2 -1
  16. package/dist/cookies.js +10 -2
  17. package/dist/formatting.d.ts +1 -1
  18. package/dist/formatting.js +1 -1
  19. package/dist/index.d.ts +3 -4
  20. package/dist/index.js +2 -2
  21. package/dist/mcp.js +2 -2
  22. package/dist/package.js +1 -1
  23. package/dist/pwa.js +1 -1
  24. package/dist/route/route.js +1 -1
  25. package/dist/server.d.ts +1 -2
  26. package/dist/server.js +1 -2
  27. package/dist/softr-config.schema.js +52 -24
  28. package/dist/vite.js +1 -19
  29. package/package.json +1 -1
  30. package/schema/softr.schema.json +13 -0
  31. package/skills/softr-sdk/SKILL.md +4 -12
  32. package/skills/softr-sdk/auth/SKILL.md +1 -1
  33. package/skills/softr-sdk/charts-and-metrics/SKILL.md +1 -1
  34. package/skills/softr-sdk/comments/SKILL.md +1 -1
  35. package/skills/softr-sdk/current-user/SKILL.md +1 -1
  36. package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
  37. package/skills/softr-sdk/formatting/SKILL.md +1 -1
  38. package/skills/softr-sdk/integrations/SKILL.md +4 -4
  39. package/skills/softr-sdk/mcp/SKILL.md +1 -1
  40. package/skills/softr-sdk/pwa/SKILL.md +1 -1
  41. package/skills/softr-sdk/record-mutations/SKILL.md +1 -1
  42. package/skills/softr-sdk/record-queries/SKILL.md +1 -1
  43. package/skills/softr-sdk/region-visibility/SKILL.md +1 -1
  44. package/skills/softr-sdk/routes-and-visibility/SKILL.md +1 -1
  45. package/skills/softr-sdk/workflows/SKILL.md +1 -1
  46. package/dist/core/ai.d.ts +0 -62
  47. package/dist/core/ai.js +0 -61
  48. package/skills/softr-sdk/ai/SKILL.md +0 -138
package/dist/action.d.ts CHANGED
@@ -264,38 +264,5 @@ type CommentsDefinition<TId extends string> = {
264
264
  * const thread = await comments.list(orderComments, { recordId })
265
265
  */
266
266
  declare function defineComments<const TId extends string>(definition: CommentsDefinition<TId>): CommentsAction<TId>;
267
- type AiResource = {
268
- type: "AI";
269
- model: string;
270
- };
271
- type AiAction<TId extends string = string> = Action<AiResource> & {
272
- id: TId;
273
- };
274
- type AiDefinition<TId extends string> = {
275
- id: TId;
276
- model: string;
277
- permissions: ActionPermissions;
278
- };
279
- /**
280
- * Declares an AI action: a model call run by Softr on its own provider keys and billed to the
281
- * workspace's AI credits, so nothing has to be connected. `model` is the id of a model from
282
- * Softr's AI catalog, chosen when the app is built: read the catalog with the `list_ai_models`
283
- * tool and copy the id exactly. An id outside the catalog is refused when the action is saved.
284
- *
285
- * Every call spends the workspace's credits, so keep the audience to signed-in users unless a
286
- * public page really needs the call.
287
- *
288
- * Pass the returned object to `ai.generate()` or `ai.stream()` at runtime.
289
- *
290
- * @example
291
- * const summarizeDeal = defineAiAction({
292
- * id: "summarizeDeal",
293
- * model: "anthropic-claude-sonnet-4-6",
294
- * permissions: { userGroupTypes: ["LOGGED_IN_USERS"] },
295
- * })
296
- *
297
- * const answer = await ai.generate(summarizeDeal, { system: "...", prompt: facts })
298
- */
299
- declare function defineAiAction<const TId extends string>(definition: AiDefinition<TId>): AiAction<TId>;
300
267
  //#endregion
301
- export { ActionData, ActionPermissions, ActionRecord, ActionShape, AiAction, CommentsAction, DatasourceAction, FieldTypeOf, IntegrationAction, ReadOf, ReadShape, SoftrDatasources, WorkflowAction, defineAiAction, defineComments, defineIntegration, defineMutation, defineQuery, defineWorkflow };
268
+ export { ActionData, ActionPermissions, ActionRecord, ActionShape, CommentsAction, DatasourceAction, FieldTypeOf, IntegrationAction, ReadOf, ReadShape, SoftrDatasources, WorkflowAction, defineComments, defineIntegration, defineMutation, defineQuery, defineWorkflow };
package/dist/action.js CHANGED
@@ -81,17 +81,6 @@ function isDatasourceAction(value) {
81
81
  const resource = candidate.resource;
82
82
  return typeof candidate.id === "string" && typeof resource === "object" && resource !== null && resource.type === "DATASOURCE";
83
83
  }
84
- function defineAiAction(definition) {
85
- const { id, model, permissions } = definition;
86
- return {
87
- id,
88
- resource: {
89
- type: "AI",
90
- model
91
- },
92
- permissions
93
- };
94
- }
95
84
 
96
85
  //#endregion
97
- export { defineAiAction, defineComments, defineIntegration, defineMutation, defineQuery, defineWorkflow, isDatasourceAction };
86
+ export { defineComments, defineIntegration, defineMutation, defineQuery, defineWorkflow, isDatasourceAction };
@@ -59,4 +59,4 @@ function throwAtCaller(f, ex) {
59
59
  }
60
60
 
61
61
  //#endregion
62
- export { assert, panic, requireDefined, softAssert };
62
+ export { assert, panic, requireDefined, softAssert, throwAtCaller };
@@ -2,7 +2,7 @@ import { User } from "../user.js";
2
2
  import { AuthError, Result } from "./utils.js";
3
3
 
4
4
  //#region src/auth/methods/profile.d.ts
5
-
5
+ type Profile = Pick<User, "id" | "email" | "fullName" | "userGroupIds" | "avatarUrl" | "avatarEditable">;
6
6
  /**
7
7
  * Changes the signed-in visitor's own name, photo, or both. A field left out
8
8
  * stays as it is. {@link removeAvatar} removes the photo.
@@ -10,7 +10,7 @@ import { AuthError, Result } from "./utils.js";
10
10
  * The photo can change only where `user.avatarEditable` is true. Elsewhere a
11
11
  * photo answers `avatarNotSupported`, so hide the photo controls instead.
12
12
  *
13
- * Answers with the visitor as they are now. The page keeps showing what it
13
+ * Answers with the visitor as they are now, without `properties`. The page keeps showing what it
14
14
  * loaded until its route context loads again, so call `router.invalidate()`
15
15
  * after a success.
16
16
  *
@@ -21,15 +21,15 @@ import { AuthError, Result } from "./utils.js";
21
21
  declare function updateProfile(changes: {
22
22
  fullName?: string;
23
23
  avatar?: File;
24
- }): Promise<Result<User, AuthError>>;
24
+ }): Promise<Result<Profile, AuthError>>;
25
25
  /**
26
26
  * Removes the signed-in visitor's own photo. Like {@link updateProfile}, it
27
27
  * works only where `user.avatarEditable` is true, and answers with the visitor
28
- * as they are now.
28
+ * as they are now, without `properties`.
29
29
  *
30
30
  * @example
31
31
  * const result = await auth.removeAvatar()
32
32
  */
33
- declare function removeAvatar(): Promise<Result<User, AuthError>>;
33
+ declare function removeAvatar(): Promise<Result<Profile, AuthError>>;
34
34
  //#endregion
35
35
  export { removeAvatar, updateProfile };
@@ -12,7 +12,7 @@ import { authError } from "./utils.js";
12
12
  * The photo can change only where `user.avatarEditable` is true. Elsewhere a
13
13
  * photo answers `avatarNotSupported`, so hide the photo controls instead.
14
14
  *
15
- * Answers with the visitor as they are now. The page keeps showing what it
15
+ * Answers with the visitor as they are now, without `properties`. The page keeps showing what it
16
16
  * loaded until its route context loads again, so call `router.invalidate()`
17
17
  * after a success.
18
18
  *
@@ -32,7 +32,7 @@ async function updateProfile(changes) {
32
32
  /**
33
33
  * Removes the signed-in visitor's own photo. Like {@link updateProfile}, it
34
34
  * works only where `user.avatarEditable` is true, and answers with the visitor
35
- * as they are now.
35
+ * as they are now, without `properties`.
36
36
  *
37
37
  * @example
38
38
  * const result = await auth.removeAvatar()
@@ -1,4 +1,3 @@
1
- import "./user.js";
2
1
  import "./context.js";
3
2
  import { z } from "zod";
4
3
 
@@ -2,7 +2,7 @@ import { assert, panic } from "../assertions.js";
2
2
  import { fetch } from "../fetch.js";
3
3
  import { permissionsSchema } from "./permissions.js";
4
4
  import { sessionConfig } from "./config.js";
5
- import { userSchema } from "./user.js";
5
+ import { softrConfig } from "../softr-config.js";
6
6
  import { z } from "zod";
7
7
 
8
8
  //#region src/auth/platform.ts
@@ -33,10 +33,32 @@ const authSettingsSchema = z.object({
33
33
  sso: z.object({ type: z.enum(["SAML", "OPEN_ID"]).nullable() }).nullable()
34
34
  });
35
35
  function makePlatform(applicationId) {
36
+ const aliases = Object.entries(softrConfig().user?.properties ?? {});
37
+ const withAttributes = aliases.length > 0 ? "?includeAttributes=true" : "";
38
+ const profileSchema = z.object({
39
+ id: z.string(),
40
+ email: z.string(),
41
+ fullName: z.string(),
42
+ userGroupIds: z.array(z.string()),
43
+ avatarUrl: z.string().nullable().default(null),
44
+ avatarEditable: z.boolean().default(false)
45
+ });
46
+ const userSchema = profileSchema.extend({ attributes: z.record(z.string(), z.unknown()).optional() }).transform(({ attributes,...identity }) => {
47
+ const properties = {};
48
+ for (const [alias, field] of aliases) {
49
+ assert(attributes !== void 0, "The signed-in user came back without their properties.");
50
+ assert(Object.hasOwn(attributes, field), `softr.json user.properties.${alias} names the field "${field}", which the users table does not have.`);
51
+ properties[alias] = attributes[field];
52
+ }
53
+ return {
54
+ ...identity,
55
+ properties
56
+ };
57
+ });
36
58
  return {
37
59
  authSettings: () => request(`/v1/vibe/applications/${applicationId}/users/auth-settings`, { method: "GET" }, authSettingsSchema),
38
60
  session: async (token) => {
39
- const result = await request(`/v1/vibe/applications/${applicationId}/users/session`, {
61
+ const result = await request(`/v1/vibe/applications/${applicationId}/users/session${withAttributes}`, {
40
62
  method: "GET",
41
63
  token
42
64
  }, z.object({
@@ -46,7 +68,7 @@ function makePlatform(applicationId) {
46
68
  if (result.status === 404) panic(`Application ${applicationId} not found. Check SOFTR_APPLICATION_ID.`);
47
69
  return result;
48
70
  },
49
- tokens: (credentials) => request(`/v1/vibe/applications/${applicationId}/users/tokens`, {
71
+ tokens: (credentials) => request(`/v1/vibe/applications/${applicationId}/users/tokens${withAttributes}`, {
50
72
  method: "POST",
51
73
  json: credentials
52
74
  }, z.object({
@@ -69,7 +91,7 @@ function makePlatform(applicationId) {
69
91
  method: "POST",
70
92
  json: { email }
71
93
  }, z.null()),
72
- signInWithCode: (credentials) => request(`/v1/vibe/applications/${applicationId}/users/tokens/with-code`, {
94
+ signInWithCode: (credentials) => request(`/v1/vibe/applications/${applicationId}/users/tokens/with-code${withAttributes}`, {
73
95
  method: "POST",
74
96
  json: credentials
75
97
  }, z.object({
@@ -88,7 +110,7 @@ function makePlatform(applicationId) {
88
110
  challengeToken: z.string()
89
111
  }).nullish()
90
112
  })),
91
- signInWithGoogle: (body) => request(`/v1/vibe/applications/${applicationId}/users/tokens/with-google`, {
113
+ signInWithGoogle: (body) => request(`/v1/vibe/applications/${applicationId}/users/tokens/with-google${withAttributes}`, {
92
114
  method: "POST",
93
115
  json: body
94
116
  }, z.object({
@@ -107,7 +129,7 @@ function makePlatform(applicationId) {
107
129
  challengeToken: z.string()
108
130
  }).nullish()
109
131
  })),
110
- signInWithMagicLink: (body) => request(`/v1/vibe/applications/${applicationId}/users/tokens/with-magic-link`, {
132
+ signInWithMagicLink: (body) => request(`/v1/vibe/applications/${applicationId}/users/tokens/with-magic-link${withAttributes}`, {
111
133
  method: "POST",
112
134
  json: body
113
135
  }, z.object({
@@ -134,7 +156,7 @@ function makePlatform(applicationId) {
134
156
  method: "POST",
135
157
  json: body
136
158
  }, z.object({ email: z.string() })),
137
- setPassword: (body) => request(`/v1/vibe/applications/${applicationId}/users/set-password`, {
159
+ setPassword: (body) => request(`/v1/vibe/applications/${applicationId}/users/set-password${withAttributes}`, {
138
160
  method: "POST",
139
161
  json: body
140
162
  }, z.object({
@@ -157,7 +179,7 @@ function makePlatform(applicationId) {
157
179
  method: "POST",
158
180
  json: body
159
181
  }, z.null()),
160
- signUpWithCode: (body) => request(`/v1/vibe/applications/${applicationId}/users/sign-up/with-code`, {
182
+ signUpWithCode: (body) => request(`/v1/vibe/applications/${applicationId}/users/sign-up/with-code${withAttributes}`, {
161
183
  method: "POST",
162
184
  json: body
163
185
  }, z.object({
@@ -176,7 +198,7 @@ function makePlatform(applicationId) {
176
198
  challengeToken: z.string()
177
199
  }).nullish()
178
200
  })),
179
- signUpWithPassword: (body) => request(`/v1/vibe/applications/${applicationId}/users/sign-up/with-password`, {
201
+ signUpWithPassword: (body) => request(`/v1/vibe/applications/${applicationId}/users/sign-up/with-password${withAttributes}`, {
180
202
  method: "POST",
181
203
  json: body
182
204
  }, z.object({
@@ -202,7 +224,7 @@ function makePlatform(applicationId) {
202
224
  otpauthUri: z.string(),
203
225
  qrPngBase64: z.string()
204
226
  })),
205
- authenticatorSetupComplete: (token, code) => request(`/v1/vibe/applications/${applicationId}/users/authenticator/setup/complete`, {
227
+ authenticatorSetupComplete: (token, code) => request(`/v1/vibe/applications/${applicationId}/users/authenticator/setup/complete${withAttributes}`, {
206
228
  method: "POST",
207
229
  token,
208
230
  json: { code }
@@ -214,7 +236,7 @@ function makePlatform(applicationId) {
214
236
  }),
215
237
  backupCodes: z.array(z.string())
216
238
  })),
217
- authenticatorSetupDismiss: (token) => request(`/v1/vibe/applications/${applicationId}/users/authenticator/setup/dismiss`, {
239
+ authenticatorSetupDismiss: (token) => request(`/v1/vibe/applications/${applicationId}/users/authenticator/setup/dismiss${withAttributes}`, {
218
240
  method: "POST",
219
241
  token
220
242
  }, z.object({
@@ -233,7 +255,7 @@ function makePlatform(applicationId) {
233
255
  challengeToken: z.string()
234
256
  }).nullish()
235
257
  })),
236
- authenticatorVerify: (token, body) => request(`/v1/vibe/applications/${applicationId}/users/authenticator/totp/verify`, {
258
+ authenticatorVerify: (token, body) => request(`/v1/vibe/applications/${applicationId}/users/authenticator/totp/verify${withAttributes}`, {
237
259
  method: "POST",
238
260
  token,
239
261
  json: body
@@ -271,11 +293,11 @@ function makePlatform(applicationId) {
271
293
  method: "PATCH",
272
294
  token,
273
295
  form
274
- }, userSchema),
296
+ }, profileSchema),
275
297
  removeAvatar: (token) => request(`/v1/vibe/applications/${applicationId}/users/me/profile/avatar`, {
276
298
  method: "DELETE",
277
299
  token
278
- }, userSchema),
300
+ }, profileSchema),
279
301
  signOut: (token) => request(`/v1/applications/${applicationId}/users/sign-out`, {
280
302
  method: "POST",
281
303
  token
@@ -2,10 +2,10 @@ import { readRequestCookies, serializeCookie, writeCookies } from "../cookies.js
2
2
  import { permissionsSchema } from "./permissions.js";
3
3
  import { sessionConfig } from "./config.js";
4
4
  import { userSchema } from "./user.js";
5
+ import { z } from "zod";
5
6
  import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
6
7
  import { AsyncLocalStorage } from "node:async_hooks";
7
8
  import { getRequestProtocol } from "@tanstack/react-start/server";
8
- import { z } from "zod";
9
9
 
10
10
  //#region src/auth/session.ts
11
11
  const SESSION_TOKEN = "session_token";
@@ -13,6 +13,12 @@ interface SoftrUserGroups {}
13
13
  type SoftrUserGroupId = SoftrUserGroups extends {
14
14
  ids: infer Id extends string;
15
15
  } ? Id : string;
16
+ /**
17
+ * The fields of the visitor's row that softr.json declares under
18
+ * `user.properties`, keyed by their aliases. The app's generated types declare
19
+ * it.
20
+ */
21
+ interface SoftrUserProperties {}
16
22
  /** The signed-in visitor. */
17
23
  type User = {
18
24
  id: string;
@@ -23,6 +29,7 @@ type User = {
23
29
  avatarUrl: string | null;
24
30
  /** Whether `auth.updateProfile` can change their photo in this app. */
25
31
  avatarEditable: boolean;
32
+ properties: SoftrUserProperties;
26
33
  };
27
34
  //#endregion
28
- export { SoftrUserGroupId, SoftrUserGroups, User };
35
+ export { SoftrUserGroupId, SoftrUserGroups, SoftrUserProperties, User };
package/dist/auth/user.js CHANGED
@@ -7,7 +7,8 @@ const userSchema = z.object({
7
7
  fullName: z.string(),
8
8
  userGroupIds: z.array(z.string()),
9
9
  avatarUrl: z.string().nullable().default(null),
10
- avatarEditable: z.boolean().default(false)
10
+ avatarEditable: z.boolean().default(false),
11
+ properties: z.record(z.string(), z.unknown())
11
12
  });
12
13
 
13
14
  //#endregion
package/dist/cli.js CHANGED
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { version } from "./package.js";
3
3
  import { requireDefined } from "./assertions.js";
4
+ import { SoftrConfigError, loadSoftrConfig } from "./softr-config.schema.js";
4
5
  import { fetchSoftrEnvInput, metadataSourceFromEnv } from "./codegen/fetch-metadata.js";
6
+ import { renderUserPropertiesDoc } from "./codegen/datasource-docs.js";
5
7
  import { renderUserGroupsDoc } from "./codegen/user-groups.js";
6
8
  import { renderSoftrEnv } from "./codegen/softr-env.js";
7
9
  import { renderSoftrFields } from "./codegen/softr-fields.js";
8
10
  import { writeIfChanged } from "./codegen/write-if-changed.js";
9
11
  import { writeDatasourceDocs } from "./codegen/write-datasource-docs.js";
10
- import { writeFileSync } from "node:fs";
12
+ import { rmSync, writeFileSync } from "node:fs";
11
13
  import path from "node:path";
12
14
  import { parseArgs } from "node:util";
13
15
 
@@ -16,9 +18,10 @@ const OUTPUT = "softr-env.d.ts";
16
18
  const FIELDS_OUTPUT = "src/softr-fields.ts";
17
19
  const DOCS_DIR = "docs/datasources";
18
20
  const USER_GROUPS_DOC = "docs/user-groups.md";
21
+ const USER_PROPERTIES_DOC = "docs/user-properties.md";
19
22
  async function generate({ json }) {
20
23
  if (json) console.info = console.error;
21
- const input = await fetchSoftrEnvInput(metadataSourceFromEnv());
24
+ const input = await fetchSoftrEnvInput(metadataSourceFromEnv(), loadSoftrConfig(process.cwd()).user?.properties ?? {});
22
25
  writeFileSync(path.resolve(process.cwd(), OUTPUT), renderSoftrEnv(input, version), "utf8");
23
26
  const report = writeDocs(input);
24
27
  report.docs.push({
@@ -33,18 +36,30 @@ async function generate({ json }) {
33
36
  printReport(report);
34
37
  }
35
38
  function writeDocs(input) {
39
+ if (input.users === null) rmSync(path.resolve(process.cwd(), USER_PROPERTIES_DOC), { force: true });
36
40
  const datasourceDocs = writeDatasourceDocs(input.datasources, path.resolve(process.cwd(), DOCS_DIR));
37
41
  return {
38
42
  types: OUTPUT,
39
- docs: [{
40
- subject: "datasources",
41
- path: `${DOCS_DIR}/README.md`,
42
- changed: datasourceDocs.changed
43
- }, {
44
- subject: "user groups",
45
- path: USER_GROUPS_DOC,
46
- changed: writeIfChanged(path.resolve(process.cwd(), USER_GROUPS_DOC), renderUserGroupsDoc(input.userGroups))
47
- }],
43
+ docs: [
44
+ {
45
+ subject: "datasources",
46
+ path: `${DOCS_DIR}/README.md`,
47
+ changed: datasourceDocs.changed
48
+ },
49
+ {
50
+ subject: "user groups",
51
+ path: USER_GROUPS_DOC,
52
+ changed: writeIfChanged(path.resolve(process.cwd(), USER_GROUPS_DOC), renderUserGroupsDoc(input.userGroups))
53
+ },
54
+ ...input.users === null ? [] : [{
55
+ subject: "user properties",
56
+ path: USER_PROPERTIES_DOC,
57
+ changed: writeIfChanged(path.resolve(process.cwd(), USER_PROPERTIES_DOC), renderUserPropertiesDoc(input.users, {
58
+ datasources: new Map(input.datasources.map((d) => [d.id, d])),
59
+ fileNames: datasourceDocs.fileNames
60
+ }))
61
+ }]
62
+ ],
48
63
  datasources: input.datasources.map(({ id, name }) => ({
49
64
  id,
50
65
  name,
@@ -68,7 +83,18 @@ const { positionals, values } = parseArgs({
68
83
  } }
69
84
  });
70
85
  const [command] = positionals;
71
- if (command === "generate") await generate({ json: values.json });
86
+ if (command === "generate") try {
87
+ await generate({ json: values.json });
88
+ } catch (error) {
89
+ if (!values.json) throw error;
90
+ const kind = error instanceof SoftrConfigError ? "softr-config" : "other";
91
+ const message = error instanceof Error ? error.message : String(error);
92
+ process.stdout.write(`${JSON.stringify({ error: {
93
+ kind,
94
+ message
95
+ } })}\n`);
96
+ process.exit(1);
97
+ }
72
98
  else {
73
99
  console.error(command ? `softr-sdk: unknown command '${command}'` : "softr-sdk: no command given");
74
100
  console.error("usage: softr-sdk generate [--json]");
@@ -18,6 +18,97 @@ function renderDatasourceDoc(datasource, context) {
18
18
  ...renderExamples(datasource, context.datasources)
19
19
  ].join("\n");
20
20
  }
21
+ function renderUserPropertiesDoc(users, context) {
22
+ const aliases = Object.entries(users.properties);
23
+ assert(aliases.length > 0, "Expected user properties to document, but got none");
24
+ const [first] = requireDefined(aliases[0]);
25
+ const registered = linkedDatasource(users.tableId, context.datasources);
26
+ const types = /* @__PURE__ */ new Set();
27
+ const sampled = aliases.filter(([, field]) => {
28
+ const type = fieldTypeLiteral(field);
29
+ if (types.has(type)) return false;
30
+ types.add(type);
31
+ return true;
32
+ });
33
+ return [
34
+ "# User properties",
35
+ "",
36
+ `Users datasource: ${displayName(users.name) || users.tableId}` + (registered === void 0 ? "" : ` \nRegistered as: [${datasourceLabel(registered)}](./datasources/${requireDefined(context.fileNames.get(registered.id))}) (${registered.id})`),
37
+ "",
38
+ "> This document is generated automatically when softr.json's `user.properties` or the users table changes.",
39
+ "",
40
+ "Each alias below is declared in softr.json under `user.properties`, and read as `user.properties.<alias>`.",
41
+ "",
42
+ "## Field reference",
43
+ "",
44
+ ...aliases.flatMap(([alias, field]) => {
45
+ const linkedId = option(field.options, "linkedTableId");
46
+ const linked = typeof linkedId === "string" ? linkedDatasource(linkedId, context.datasources) : void 0;
47
+ return [
48
+ `### ${alias}`,
49
+ "",
50
+ `- Field: ${displayName(field.name)}`,
51
+ `- ID: ${field.id}`,
52
+ `- Type: ${fieldTypeLiteral(field).slice(1, -1)}`,
53
+ ...optionLines(field),
54
+ ...choiceLines(field),
55
+ ...typeof linkedId !== "string" ? [] : linked === void 0 ? [`- Links to: ${linkedId}, not registered`] : [`- Links to: [${datasourceLabel(linked)}](./datasources/${requireDefined(context.fileNames.get(linked.id))}) (${linked.id})`],
56
+ ""
57
+ ];
58
+ }),
59
+ "## Read shape",
60
+ "",
61
+ "In a component:",
62
+ "",
63
+ "```tsx",
64
+ "import { useCurrentUser } from '@softr/sdk'",
65
+ "",
66
+ "const user = useCurrentUser()",
67
+ "if (user === null) return null",
68
+ "",
69
+ ...sampled.map(([alias, field]) => `user.properties.${alias} // ${fieldTypeLiteral(field).slice(1, -1)}`),
70
+ "```",
71
+ "",
72
+ "In a route's `beforeLoad`, to send a visitor elsewhere. It guards navigation only, not data:",
73
+ "",
74
+ "```tsx",
75
+ "import { createFileRoute, redirect } from '@tanstack/react-router'",
76
+ "import { assertRouteAccess, defineVisibility } from '@softr/sdk'",
77
+ "",
78
+ "const visibility = defineVisibility({ userGroupTypes: ['LOGGED_IN_USERS'] })",
79
+ "",
80
+ "export const Route = createFileRoute('/example')({",
81
+ " beforeLoad: (opts) => {",
82
+ " assertRouteAccess(visibility, opts)",
83
+ ` if (opts.context.user.properties.${first} == null) throw redirect({ to: '/' })`,
84
+ " },",
85
+ "})",
86
+ "```",
87
+ "",
88
+ "In a server function, called from a route's `loader`. The handler checks the visitor itself:",
89
+ "",
90
+ "```tsx",
91
+ "import { createFileRoute } from '@tanstack/react-router'",
92
+ "import { createServerFn } from '@tanstack/react-start'",
93
+ "",
94
+ `const get${pascalCase(first)} = createServerFn().handler(async ({ context }) => {`,
95
+ " if (!context.user) throw new Error('Not signed in')",
96
+ ` return context.user.properties.${first}`,
97
+ "})",
98
+ "",
99
+ "export const Route = createFileRoute('/example')({",
100
+ ` loader: () => get${pascalCase(first)}(),`,
101
+ " component: Example,",
102
+ "})",
103
+ "",
104
+ "function Example() {",
105
+ ` const ${first} = Route.useLoaderData()`,
106
+ " // ...",
107
+ "}",
108
+ "```",
109
+ ""
110
+ ].join("\n");
111
+ }
21
112
  /** Two trailing spaces are a markdown hard break, which keeps the block one paragraph. */
22
113
  function summary(datasource, context) {
23
114
  const lines = [`Datasource ID: ${datasource.id}`];
@@ -315,4 +406,4 @@ function words(value) {
315
406
  }
316
407
 
317
408
  //#endregion
318
- export { DOC_NOTE, renderDatasourceDoc, renderDatasourceIndex };
409
+ export { DOC_NOTE, renderDatasourceDoc, renderDatasourceIndex, renderUserPropertiesDoc };
@@ -238,6 +238,21 @@ function linkedRecord(options, context) {
238
238
  if (linked === void 0 || linked.primaryFieldId === "") return "LinkedRecord";
239
239
  return `LinkedRecord<NonNullable<${`${nameOf(linked.id, context)}Read[${quote(linked.primaryFieldId)}]`}>>`;
240
240
  }
241
+ /** `SoftrUserProperties`, each alias typed as its field reads. */
242
+ function renderUserProperties(properties, datasources) {
243
+ const context = {
244
+ datasources: byId(datasources),
245
+ names: typeNames(datasources)
246
+ };
247
+ return [
248
+ "interface SoftrUserProperties {",
249
+ ...Object.entries(properties).map(([alias, field]) => indent(readMember({
250
+ ...field,
251
+ id: alias
252
+ }, context))),
253
+ "}"
254
+ ].join("\n");
255
+ }
241
256
  function renderRegistry(datasources, context) {
242
257
  return [
243
258
  "interface SoftrDatasources {",
@@ -481,4 +496,4 @@ function indent(block) {
481
496
  }
482
497
 
483
498
  //#endregion
484
- export { choiceEntries, fieldTypeLiteral, isWritable, linkedDatasource, option, readExample, renderDatasourceTypes, resolvedShape, scalarExample, underlyingType };
499
+ export { choiceEntries, fieldTypeLiteral, isWritable, linkedDatasource, option, readExample, renderDatasourceTypes, renderUserProperties, resolvedShape, scalarExample, underlyingType };
@@ -1,5 +1,6 @@
1
1
  import { panic } from "../assertions.js";
2
2
  import { fetch } from "../fetch.js";
3
+ import { panicConfig } from "../softr-config.schema.js";
3
4
 
4
5
  //#region src/codegen/fetch-metadata.ts
5
6
  function metadataSourceFromEnv(env = process.env) {
@@ -17,20 +18,35 @@ function metadataSourceFromEnv(env = process.env) {
17
18
  token: env.SOFTR_STUDIO_TOKEN
18
19
  };
19
20
  }
20
- async function fetchSoftrEnvInput(source) {
21
+ async function fetchSoftrEnvInput(source, aliases) {
21
22
  const { studioApiUrl, datasourceApiUrl, applicationId, token } = source;
22
23
  const [registered, userGroups] = await Promise.all([apiFetch(`${studioApiUrl}/v1/applications/${applicationId}/datasources`, token), apiFetch(`${studioApiUrl}/v1/applications/${applicationId}/access-control/user-groups`, token)]);
24
+ const datasources = await Promise.all(registered.map(async ({ name, datasource }) => {
25
+ const metadata = await apiFetch(`${datasourceApiUrl}/v1/datasource/vibe/studio/applications/${applicationId}/datasources/${datasource.id}/metadata`, token);
26
+ return {
27
+ ...metadata,
28
+ id: datasource.id,
29
+ tableId: metadata.id,
30
+ name
31
+ };
32
+ }));
33
+ let users = null;
34
+ if (Object.keys(aliases).length > 0) {
35
+ const metadata = await apiFetch(`${datasourceApiUrl}/v1/datasource/vibe/studio/applications/${applicationId}/user-connection/metadata`, token);
36
+ users = {
37
+ tableId: metadata.id,
38
+ name: metadata.name,
39
+ properties: {}
40
+ };
41
+ for (const [alias, fieldId] of Object.entries(aliases)) {
42
+ const field = metadata.fields.find(({ id }) => id === fieldId) ?? panicConfig(`softr.json user.properties.${alias} names "${fieldId}", which is not a field of the users table.`);
43
+ users.properties[alias] = field;
44
+ }
45
+ }
23
46
  return {
24
- datasources: await Promise.all(registered.map(async ({ name, datasource }) => {
25
- const metadata = await apiFetch(`${datasourceApiUrl}/v1/datasource/vibe/studio/applications/${applicationId}/datasources/${datasource.id}/metadata`, token);
26
- return {
27
- ...metadata,
28
- id: datasource.id,
29
- tableId: metadata.id,
30
- name
31
- };
32
- })),
33
- userGroups
47
+ datasources,
48
+ userGroups,
49
+ users
34
50
  };
35
51
  }
36
52
  async function apiFetch(url, token) {
@@ -1,4 +1,4 @@
1
- import { renderDatasourceTypes } from "./datasource-types.js";
1
+ import { renderDatasourceTypes, renderUserProperties } from "./datasource-types.js";
2
2
  import { renderUserGroups } from "./user-groups.js";
3
3
 
4
4
  //#region src/codegen/softr-env.ts
@@ -15,6 +15,7 @@ function renderSoftrEnv(input, sdkVersion) {
15
15
  indent(interfaces),
16
16
  "",
17
17
  indent(renderUserGroups(input.userGroups)),
18
+ ...input.users === null ? [] : ["", indent(renderUserProperties(input.users.properties, input.datasources))],
18
19
  "}",
19
20
  ""
20
21
  ].join("\n");
package/dist/cookies.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { softAssert } from "./assertions.js";
1
2
  import { getRequestHeader, getResponseHeaders, setResponseHeader } from "@tanstack/react-start/server";
2
3
 
3
4
  //#region src/cookies.ts
@@ -42,9 +43,16 @@ const USER_TIMEZONE_COOKIE = "user-timezone";
42
43
  function readUserTimezone() {
43
44
  return readRequestCookies()[USER_TIMEZONE_COOKIE] ?? null;
44
45
  }
46
+ /** What browsers keep of one cookie's name and value. They drop a larger one silently. */
47
+ const COOKIE_MAX_BYTES = 4096;
45
48
  function writeCookies(cookies) {
46
- if (cookies.length === 0) return;
47
- setResponseHeader("Set-Cookie", [...getResponseHeaders().getSetCookie(), ...cookies]);
49
+ const written = cookies.filter((cookie) => {
50
+ const size = cookie.split(";")[0].length;
51
+ softAssert(size <= COOKIE_MAX_BYTES, () => `The ${cookie.slice(0, cookie.indexOf("="))} cookie is ${size} bytes, over the ${COOKIE_MAX_BYTES} a browser keeps. It was not written.`);
52
+ return size <= COOKIE_MAX_BYTES;
53
+ });
54
+ if (written.length === 0) return;
55
+ setResponseHeader("Set-Cookie", [...getResponseHeaders().getSetCookie(), ...written]);
48
56
  }
49
57
 
50
58
  //#endregion
@@ -1,6 +1,6 @@
1
+ import { z } from "zod";
1
2
  import * as react0 from "react";
2
3
  import { ReactNode } from "react";
3
- import { z } from "zod";
4
4
 
5
5
  //#region src/formatting.d.ts
6
6
  type FixedDateFormat = "FRIENDLY" | "DD-MM-YYYY" | "MM-DD-YYYY" | "YYYY-MM-DD";
@@ -1,6 +1,6 @@
1
1
  import { logger } from "./logger.js";
2
- import { Fragment, createContext, createElement, useContext, useMemo, useSyncExternalStore } from "react";
3
2
  import { z } from "zod";
3
+ import { Fragment, createContext, createElement, useContext, useMemo, useSyncExternalStore } from "react";
4
4
  import { rootRouteId, useRouteContext } from "@tanstack/react-router";
5
5
 
6
6
  //#region src/formatting.ts