@softr/sdk 0.0.68-props.0 → 0.0.69-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/auth/current-user-row.d.ts +20 -0
  2. package/dist/auth/current-user-row.js +22 -0
  3. package/dist/auth/methods/profile.d.ts +26 -0
  4. package/dist/auth/methods/profile.js +52 -0
  5. package/dist/auth/methods/utils.d.ts +1 -1
  6. package/dist/auth/platform.d.ts +1 -0
  7. package/dist/auth/platform.js +20 -33
  8. package/dist/auth/server.d.ts +4 -0
  9. package/dist/auth/server.js +5 -1
  10. package/dist/auth/session.js +12 -2
  11. package/dist/auth/user.d.ts +5 -8
  12. package/dist/auth/user.js +2 -1
  13. package/dist/cli.js +18 -26
  14. package/dist/codegen/datasource-docs.js +1 -93
  15. package/dist/codegen/datasource-types.js +1 -16
  16. package/dist/codegen/fetch-metadata.js +17 -44
  17. package/dist/codegen/softr-env.js +1 -2
  18. package/dist/codegen/softr-fields.js +41 -0
  19. package/dist/cookies.js +2 -10
  20. package/dist/core/options.d.ts +36 -0
  21. package/dist/core/options.js +39 -0
  22. package/dist/core/use-overrides.d.ts +17 -0
  23. package/dist/core/use-overrides.js +30 -0
  24. package/dist/datasource/datasource.d.ts +2 -0
  25. package/dist/datasource/datasource.js +3 -1
  26. package/dist/datasource/fields.js +3 -2
  27. package/dist/datasource/record.d.ts +2 -1
  28. package/dist/default-error-overlay.d.ts +2 -2
  29. package/dist/fields/format.d.ts +45 -0
  30. package/dist/fields/format.js +140 -0
  31. package/dist/formatting.d.ts +4 -4
  32. package/dist/formatting.js +1 -1
  33. package/dist/index.d.ts +4 -2
  34. package/dist/index.js +3 -1
  35. package/dist/mcp.d.ts +2 -2
  36. package/dist/mcp.js +2 -2
  37. package/dist/package.js +1 -1
  38. package/dist/pwa.d.ts +2 -2
  39. package/dist/pwa.js +1 -1
  40. package/dist/server.d.ts +2 -1
  41. package/dist/softr-config.schema.js +24 -45
  42. package/dist/visibility/route.js +1 -1
  43. package/dist/vite.js +19 -1
  44. package/package.json +10 -33
  45. package/schema/softr.schema.json +0 -13
  46. package/skills/softr-sdk/SKILL.md +2 -2
  47. package/skills/softr-sdk/auth/SKILL.md +29 -10
  48. package/skills/softr-sdk/charts-and-metrics/SKILL.md +1 -1
  49. package/skills/softr-sdk/current-user/SKILL.md +5 -24
  50. package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
  51. package/skills/softr-sdk/formatting/SKILL.md +19 -1
  52. package/skills/softr-sdk/integrations/SKILL.md +1 -1
  53. package/skills/softr-sdk/mcp/SKILL.md +1 -1
  54. package/skills/softr-sdk/pwa/SKILL.md +1 -1
  55. package/skills/softr-sdk/record-mutations/SKILL.md +13 -1
  56. package/skills/softr-sdk/record-queries/SKILL.md +21 -1
  57. package/skills/softr-sdk/region-visibility/SKILL.md +1 -1
  58. package/skills/softr-sdk/routes-and-visibility/SKILL.md +1 -1
  59. package/skills/softr-sdk/workflows/SKILL.md +1 -1
@@ -0,0 +1,20 @@
1
+ import { User } from "./user.js";
2
+ import { DatasourceAction, ReadShape } from "../action.js";
3
+ import { GetRecord } from "../datasource/record.js";
4
+ import "@tanstack/react-start/server-only";
5
+
6
+ //#region src/auth/current-user-row.d.ts
7
+
8
+ /**
9
+ * The signed-in visitor's own row of the Users table, found by email, or null when they are signed out
10
+ * or the table holds no row for them. For a "mine" filter, an owner set on create, or a page scoped to
11
+ * what the visitor owns: the row's id is what a link to Users holds.
12
+ *
13
+ * @example
14
+ * const me = await auth.currentUserRow(context.user, usersQuery, 'email')
15
+ * if (me === null) return []
16
+ * return ds.list(tasks).where((q) => q.assignee.isOneOf([me.id]))
17
+ */
18
+ declare function currentUserRow<TShape extends ReadShape>(user: User | null, usersQuery: DatasourceAction<TShape>, email: keyof TShape["fields"] & string): Promise<GetRecord<TShape>>;
19
+ //#endregion
20
+ export { currentUserRow };
@@ -0,0 +1,22 @@
1
+ import { makeChain } from "../datasource/fields.js";
2
+ import { listRecords } from "../datasource/record.js";
3
+ import "@tanstack/react-start/server-only";
4
+
5
+ //#region src/auth/current-user-row.ts
6
+ /**
7
+ * The signed-in visitor's own row of the Users table, found by email, or null when they are signed out
8
+ * or the table holds no row for them. For a "mine" filter, an owner set on create, or a page scoped to
9
+ * what the visitor owns: the row's id is what a link to Users holds.
10
+ *
11
+ * @example
12
+ * const me = await auth.currentUserRow(context.user, usersQuery, 'email')
13
+ * if (me === null) return []
14
+ * return ds.list(tasks).where((q) => q.assignee.isOneOf([me.id]))
15
+ */
16
+ async function currentUserRow(user, usersQuery, email) {
17
+ if (user === null) return null;
18
+ return (await listRecords(usersQuery).where(() => makeChain(email).is(user.email)).limit(1)).records[0] ?? null;
19
+ }
20
+
21
+ //#endregion
22
+ export { currentUserRow };
@@ -0,0 +1,26 @@
1
+ import { User } from "../user.js";
2
+ import { AuthError, Result } from "./utils.js";
3
+
4
+ //#region src/auth/methods/profile.d.ts
5
+
6
+ /**
7
+ * Changes the signed-in visitor's own name, photo, or both. A field left out
8
+ * stays as it is, and `avatar: null` removes the photo.
9
+ *
10
+ * The photo can change only where `user.avatarEditable` is true. Elsewhere a
11
+ * photo answers `avatarNotSupported`, so hide the photo controls instead.
12
+ *
13
+ * Answers with the visitor as they are now. The page keeps showing what it
14
+ * loaded until its route context loads again, so call `router.invalidate()`
15
+ * after a success.
16
+ *
17
+ * @example
18
+ * const result = await auth.updateProfile({ fullName: 'Ann Lee' })
19
+ * const result = await auth.updateProfile({ avatar: file })
20
+ */
21
+ declare function updateProfile(changes: {
22
+ fullName?: string;
23
+ avatar?: File | null;
24
+ }): Promise<Result<User, AuthError>>;
25
+ //#endregion
26
+ export { updateProfile };
@@ -0,0 +1,52 @@
1
+ import { assert, panic } from "../../assertions.js";
2
+ import { sessionConfig } from "../config.js";
3
+ import { clearSessionData, readSessionToken } from "../session.js";
4
+ import { makePlatform } from "../platform.js";
5
+ import { authError } from "./utils.js";
6
+
7
+ //#region src/auth/methods/profile.ts
8
+ /**
9
+ * Changes the signed-in visitor's own name, photo, or both. A field left out
10
+ * stays as it is, and `avatar: null` removes the photo.
11
+ *
12
+ * The photo can change only where `user.avatarEditable` is true. Elsewhere a
13
+ * photo answers `avatarNotSupported`, so hide the photo controls instead.
14
+ *
15
+ * Answers with the visitor as they are now. The page keeps showing what it
16
+ * loaded until its route context loads again, so call `router.invalidate()`
17
+ * after a success.
18
+ *
19
+ * @example
20
+ * const result = await auth.updateProfile({ fullName: 'Ann Lee' })
21
+ * const result = await auth.updateProfile({ avatar: file })
22
+ */
23
+ async function updateProfile(changes) {
24
+ assert(changes.fullName !== void 0 || changes.avatar !== void 0, "updateProfile needs a name or a photo to change");
25
+ const token = readSessionToken();
26
+ if (token === null) return authError("notAuthenticated", "Nobody is signed in.");
27
+ const form = new FormData();
28
+ if (changes.fullName !== void 0) form.set("fullName", changes.fullName);
29
+ if (changes.avatar === null) form.set("removeAvatar", "true");
30
+ if (changes.avatar) form.set("avatar", changes.avatar);
31
+ const result = await makePlatform(sessionConfig().applicationId).updateProfile(token, form);
32
+ if (!result.ok) {
33
+ switch (result.status) {
34
+ case 400: return authError("invalidName", result.message);
35
+ case 401: return authError("notAuthenticated", result.message);
36
+ case 409: return authError("avatarNotSupported", result.message);
37
+ case 413: return authError("fileTooLarge", result.message);
38
+ case 415: return authError("notAnImage", result.message);
39
+ case 429: return authError("tooManyAttempts", result.message);
40
+ }
41
+ if (result.status >= 500) panic(`Expected the profile to update, but got ${result.status}: ` + result.message);
42
+ throw new Error(`${result.message}. Check SOFTR_APPLICATION_ID.`);
43
+ }
44
+ clearSessionData();
45
+ return {
46
+ success: true,
47
+ data: result.data
48
+ };
49
+ }
50
+
51
+ //#endregion
52
+ export { updateProfile };
@@ -10,6 +10,6 @@ type AuthError = {
10
10
  code: AuthErrorCode;
11
11
  message: string;
12
12
  };
13
- type AuthErrorCode = "invalidCredentials" | "tooManyAttempts" | "emailTaken" | "invalidDetails" | "invalidCode" | "notAuthenticated" | "emailDomainNotAllowed" | "linkExpired";
13
+ type AuthErrorCode = "invalidCredentials" | "tooManyAttempts" | "emailTaken" | "invalidDetails" | "invalidCode" | "notAuthenticated" | "emailDomainNotAllowed" | "linkExpired" | "invalidName" | "fileTooLarge" | "notAnImage" | "avatarNotSupported";
14
14
  //#endregion
15
15
  export { AuthError, Result };
@@ -1,3 +1,4 @@
1
+ import "./user.js";
1
2
  import "./context.js";
2
3
  import { z } from "zod";
3
4
 
@@ -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 { softrConfig } from "../softr-config.js";
5
+ import { userSchema } from "./user.js";
6
6
  import { z } from "zod";
7
7
 
8
8
  //#region src/auth/platform.ts
@@ -33,30 +33,10 @@ 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 userSchema = z.object({
39
- id: z.string(),
40
- email: z.string(),
41
- fullName: z.string(),
42
- userGroupIds: z.array(z.string()),
43
- attributes: z.record(z.string(), z.unknown()).optional()
44
- }).transform(({ attributes,...identity }) => {
45
- const properties = {};
46
- for (const [alias, field] of aliases) {
47
- assert(attributes !== void 0, "The signed-in user came back without their properties.");
48
- assert(Object.hasOwn(attributes, field), `softr.json user.properties.${alias} names the field "${field}", which the users table does not have.`);
49
- properties[alias] = attributes[field];
50
- }
51
- return {
52
- ...identity,
53
- properties
54
- };
55
- });
56
36
  return {
57
37
  authSettings: () => request(`/v1/vibe/applications/${applicationId}/users/auth-settings`, { method: "GET" }, authSettingsSchema),
58
38
  session: async (token) => {
59
- const result = await request(`/v1/vibe/applications/${applicationId}/users/session${withAttributes}`, {
39
+ const result = await request(`/v1/vibe/applications/${applicationId}/users/session`, {
60
40
  method: "GET",
61
41
  token
62
42
  }, z.object({
@@ -66,7 +46,7 @@ function makePlatform(applicationId) {
66
46
  if (result.status === 404) panic(`Application ${applicationId} not found. Check SOFTR_APPLICATION_ID.`);
67
47
  return result;
68
48
  },
69
- tokens: (credentials) => request(`/v1/vibe/applications/${applicationId}/users/tokens${withAttributes}`, {
49
+ tokens: (credentials) => request(`/v1/vibe/applications/${applicationId}/users/tokens`, {
70
50
  method: "POST",
71
51
  json: credentials
72
52
  }, z.object({
@@ -89,7 +69,7 @@ function makePlatform(applicationId) {
89
69
  method: "POST",
90
70
  json: { email }
91
71
  }, z.null()),
92
- signInWithCode: (credentials) => request(`/v1/vibe/applications/${applicationId}/users/tokens/with-code${withAttributes}`, {
72
+ signInWithCode: (credentials) => request(`/v1/vibe/applications/${applicationId}/users/tokens/with-code`, {
93
73
  method: "POST",
94
74
  json: credentials
95
75
  }, z.object({
@@ -108,7 +88,7 @@ function makePlatform(applicationId) {
108
88
  challengeToken: z.string()
109
89
  }).nullish()
110
90
  })),
111
- signInWithGoogle: (body) => request(`/v1/vibe/applications/${applicationId}/users/tokens/with-google${withAttributes}`, {
91
+ signInWithGoogle: (body) => request(`/v1/vibe/applications/${applicationId}/users/tokens/with-google`, {
112
92
  method: "POST",
113
93
  json: body
114
94
  }, z.object({
@@ -127,7 +107,7 @@ function makePlatform(applicationId) {
127
107
  challengeToken: z.string()
128
108
  }).nullish()
129
109
  })),
130
- signInWithMagicLink: (body) => request(`/v1/vibe/applications/${applicationId}/users/tokens/with-magic-link${withAttributes}`, {
110
+ signInWithMagicLink: (body) => request(`/v1/vibe/applications/${applicationId}/users/tokens/with-magic-link`, {
131
111
  method: "POST",
132
112
  json: body
133
113
  }, z.object({
@@ -154,7 +134,7 @@ function makePlatform(applicationId) {
154
134
  method: "POST",
155
135
  json: body
156
136
  }, z.object({ email: z.string() })),
157
- setPassword: (body) => request(`/v1/vibe/applications/${applicationId}/users/set-password${withAttributes}`, {
137
+ setPassword: (body) => request(`/v1/vibe/applications/${applicationId}/users/set-password`, {
158
138
  method: "POST",
159
139
  json: body
160
140
  }, z.object({
@@ -177,7 +157,7 @@ function makePlatform(applicationId) {
177
157
  method: "POST",
178
158
  json: body
179
159
  }, z.null()),
180
- signUpWithCode: (body) => request(`/v1/vibe/applications/${applicationId}/users/sign-up/with-code${withAttributes}`, {
160
+ signUpWithCode: (body) => request(`/v1/vibe/applications/${applicationId}/users/sign-up/with-code`, {
181
161
  method: "POST",
182
162
  json: body
183
163
  }, z.object({
@@ -196,7 +176,7 @@ function makePlatform(applicationId) {
196
176
  challengeToken: z.string()
197
177
  }).nullish()
198
178
  })),
199
- signUpWithPassword: (body) => request(`/v1/vibe/applications/${applicationId}/users/sign-up/with-password${withAttributes}`, {
179
+ signUpWithPassword: (body) => request(`/v1/vibe/applications/${applicationId}/users/sign-up/with-password`, {
200
180
  method: "POST",
201
181
  json: body
202
182
  }, z.object({
@@ -222,7 +202,7 @@ function makePlatform(applicationId) {
222
202
  otpauthUri: z.string(),
223
203
  qrPngBase64: z.string()
224
204
  })),
225
- authenticatorSetupComplete: (token, code) => request(`/v1/vibe/applications/${applicationId}/users/authenticator/setup/complete${withAttributes}`, {
205
+ authenticatorSetupComplete: (token, code) => request(`/v1/vibe/applications/${applicationId}/users/authenticator/setup/complete`, {
226
206
  method: "POST",
227
207
  token,
228
208
  json: { code }
@@ -234,7 +214,7 @@ function makePlatform(applicationId) {
234
214
  }),
235
215
  backupCodes: z.array(z.string())
236
216
  })),
237
- authenticatorSetupDismiss: (token) => request(`/v1/vibe/applications/${applicationId}/users/authenticator/setup/dismiss${withAttributes}`, {
217
+ authenticatorSetupDismiss: (token) => request(`/v1/vibe/applications/${applicationId}/users/authenticator/setup/dismiss`, {
238
218
  method: "POST",
239
219
  token
240
220
  }, z.object({
@@ -253,7 +233,7 @@ function makePlatform(applicationId) {
253
233
  challengeToken: z.string()
254
234
  }).nullish()
255
235
  })),
256
- authenticatorVerify: (token, body) => request(`/v1/vibe/applications/${applicationId}/users/authenticator/totp/verify${withAttributes}`, {
236
+ authenticatorVerify: (token, body) => request(`/v1/vibe/applications/${applicationId}/users/authenticator/totp/verify`, {
257
237
  method: "POST",
258
238
  token,
259
239
  json: body
@@ -287,6 +267,11 @@ function makePlatform(applicationId) {
287
267
  token,
288
268
  json: { code }
289
269
  }, z.null()),
270
+ updateProfile: (token, form) => request(`/v1/vibe/applications/${applicationId}/users/me/profile`, {
271
+ method: "PATCH",
272
+ token,
273
+ form
274
+ }, userSchema),
290
275
  signOut: (token) => request(`/v1/applications/${applicationId}/users/sign-out`, {
291
276
  method: "POST",
292
277
  token
@@ -295,6 +280,7 @@ function makePlatform(applicationId) {
295
280
  }
296
281
  const errorSchema = z.object({ message: z.string() });
297
282
  async function request(path, init, schema, parse = (response$1) => response$1.json()) {
283
+ assert(init.json === void 0 || init.form === void 0, "A platform request carries a JSON body or a form, not both");
298
284
  const response = await fetch(`${await platformApiUrl()}${path}`, {
299
285
  method: init.method,
300
286
  headers: {
@@ -302,7 +288,8 @@ async function request(path, init, schema, parse = (response$1) => response$1.js
302
288
  ...init.json === void 0 ? {} : { "content-type": "application/json" },
303
289
  ...init.token ? { authorization: `Bearer ${init.token}` } : {}
304
290
  },
305
- ...init.json === void 0 ? {} : { body: JSON.stringify(init.json) }
291
+ ...init.json === void 0 ? {} : { body: JSON.stringify(init.json) },
292
+ ...init.form === void 0 ? {} : { body: init.form }
306
293
  });
307
294
  if (!response.ok) {
308
295
  const error = errorSchema.safeParse(await response.json().catch(() => null));
@@ -6,6 +6,8 @@ import { checkPasswordLink, requestPasswordReset, setPasswordWithLink } from "./
6
6
  import { settings } from "./methods/settings.js";
7
7
  import { confirmSignUp, signUp } from "./methods/signup.js";
8
8
  import { signInWithGoogle } from "./methods/social.js";
9
+ import { updateProfile } from "./methods/profile.js";
10
+ import { currentUserRow } from "./current-user-row.js";
9
11
  import "@tanstack/react-start/server-only";
10
12
 
11
13
  //#region src/auth/server.d.ts
@@ -33,6 +35,8 @@ declare const auth: {
33
35
  resetAuthenticator: typeof resetAuthenticator;
34
36
  removeAuthenticator: typeof removeAuthenticator;
35
37
  settings: typeof settings;
38
+ updateProfile: typeof updateProfile;
39
+ currentUserRow: typeof currentUserRow;
36
40
  };
37
41
  //#endregion
38
42
  export { auth };
@@ -6,6 +6,8 @@ import { signIn, signOut } from "./methods/password.js";
6
6
  import { checkPasswordLink, requestPasswordReset, setPasswordWithLink } from "./methods/passwordLink.js";
7
7
  import { confirmSignUp, signUp } from "./methods/signup.js";
8
8
  import { signInWithGoogle } from "./methods/social.js";
9
+ import { updateProfile } from "./methods/profile.js";
10
+ import { currentUserRow } from "./current-user-row.js";
9
11
  import "@tanstack/react-start/server-only";
10
12
 
11
13
  //#region src/auth/server.ts
@@ -31,7 +33,9 @@ const auth = {
31
33
  sendAuthenticatorResetCode,
32
34
  resetAuthenticator,
33
35
  removeAuthenticator,
34
- settings
36
+ settings,
37
+ updateProfile,
38
+ currentUserRow
35
39
  };
36
40
 
37
41
  //#endregion
@@ -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";
6
5
  import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
7
6
  import { AsyncLocalStorage } from "node:async_hooks";
8
7
  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";
@@ -118,6 +118,16 @@ function clearSession() {
118
118
  PENDING_AUTH
119
119
  ]);
120
120
  }
121
+ /**
122
+ * Drops the cached visitor and leaves the session signed in, so the next
123
+ * request needing a visitor re-reads one from the platform.
124
+ *
125
+ * For a mutation to what the visitor's own record holds — their name, their
126
+ * groups — which the cache would otherwise answer stale for up to five minutes.
127
+ */
128
+ function clearSessionData() {
129
+ clearCookies([SESSION_DATA]);
130
+ }
121
131
  const standInToken = new AsyncLocalStorage();
122
132
  const sessionDataSchema = z.object({
123
133
  user: userSchema.nullable(),
@@ -210,4 +220,4 @@ function openEnvelope(raw, purpose) {
210
220
  }
211
221
 
212
222
  //#endregion
213
- export { clearPendingAuth, clearSession, makePendingAuthCookie, makeSessionDataCookie, makeSessionTokenCookie, readPendingAuthToken, readSessionData, readSessionToken, withSessionToken };
223
+ export { clearPendingAuth, clearSession, clearSessionData, makePendingAuthCookie, makeSessionDataCookie, makeSessionTokenCookie, readPendingAuthToken, readSessionData, readSessionToken, withSessionToken };
@@ -13,19 +13,16 @@ 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 {}
22
16
  /** The signed-in visitor. */
23
17
  type User = {
24
18
  id: string;
25
19
  email: string;
26
20
  fullName: string;
27
21
  userGroupIds: Array<SoftrUserGroupId>;
28
- properties: SoftrUserProperties;
22
+ /** Their photo, or `null` when they have none. Show their initials then. */
23
+ avatarUrl: string | null;
24
+ /** Whether `auth.updateProfile` can change their photo in this app. */
25
+ avatarEditable: boolean;
29
26
  };
30
27
  //#endregion
31
- export { SoftrUserGroupId, SoftrUserGroups, SoftrUserProperties, User };
28
+ export { SoftrUserGroupId, SoftrUserGroups, User };
package/dist/auth/user.js CHANGED
@@ -6,7 +6,8 @@ const userSchema = z.object({
6
6
  email: z.string(),
7
7
  fullName: z.string(),
8
8
  userGroupIds: z.array(z.string()),
9
- properties: z.record(z.string(), z.unknown())
9
+ avatarUrl: z.string().nullable().default(null),
10
+ avatarEditable: z.boolean().default(false)
10
11
  });
11
12
 
12
13
  //#endregion
package/dist/cli.js CHANGED
@@ -2,26 +2,30 @@
2
2
  import { version } from "./package.js";
3
3
  import { requireDefined } from "./assertions.js";
4
4
  import { fetchSoftrEnvInput, metadataSourceFromEnv } from "./codegen/fetch-metadata.js";
5
- import { renderUserPropertiesDoc } from "./codegen/datasource-docs.js";
6
5
  import { renderUserGroupsDoc } from "./codegen/user-groups.js";
7
6
  import { renderSoftrEnv } from "./codegen/softr-env.js";
7
+ import { renderSoftrFields } from "./codegen/softr-fields.js";
8
8
  import { writeIfChanged } from "./codegen/write-if-changed.js";
9
9
  import { writeDatasourceDocs } from "./codegen/write-datasource-docs.js";
10
- import { loadSoftrConfig } from "./softr-config.schema.js";
11
- import { rmSync, writeFileSync } from "node:fs";
10
+ import { writeFileSync } from "node:fs";
12
11
  import path from "node:path";
13
12
  import { parseArgs } from "node:util";
14
13
 
15
14
  //#region src/cli.ts
16
15
  const OUTPUT = "softr-env.d.ts";
16
+ const FIELDS_OUTPUT = "src/softr-fields.ts";
17
17
  const DOCS_DIR = "docs/datasources";
18
18
  const USER_GROUPS_DOC = "docs/user-groups.md";
19
- const USER_PROPERTIES_DOC = "docs/user-properties.md";
20
19
  async function generate({ json }) {
21
20
  if (json) console.info = console.error;
22
- const input = await fetchSoftrEnvInput(metadataSourceFromEnv(), loadSoftrConfig(process.cwd()).user?.properties ?? {});
21
+ const input = await fetchSoftrEnvInput(metadataSourceFromEnv());
23
22
  writeFileSync(path.resolve(process.cwd(), OUTPUT), renderSoftrEnv(input, version), "utf8");
24
23
  const report = writeDocs(input);
24
+ report.docs.push({
25
+ subject: "field shapes",
26
+ path: FIELDS_OUTPUT,
27
+ changed: writeIfChanged(path.resolve(process.cwd(), FIELDS_OUTPUT), renderSoftrFields(input.datasources, version))
28
+ });
25
29
  if (json) {
26
30
  process.stdout.write(`${JSON.stringify(report)}\n`);
27
31
  return;
@@ -29,30 +33,18 @@ async function generate({ json }) {
29
33
  printReport(report);
30
34
  }
31
35
  function writeDocs(input) {
32
- if (input.users === null) rmSync(path.resolve(process.cwd(), USER_PROPERTIES_DOC), { force: true });
33
36
  const datasourceDocs = writeDatasourceDocs(input.datasources, path.resolve(process.cwd(), DOCS_DIR));
34
37
  return {
35
38
  types: OUTPUT,
36
- docs: [
37
- {
38
- subject: "datasources",
39
- path: `${DOCS_DIR}/README.md`,
40
- changed: datasourceDocs.changed
41
- },
42
- {
43
- subject: "user groups",
44
- path: USER_GROUPS_DOC,
45
- changed: writeIfChanged(path.resolve(process.cwd(), USER_GROUPS_DOC), renderUserGroupsDoc(input.userGroups))
46
- },
47
- ...input.users === null ? [] : [{
48
- subject: "user properties",
49
- path: USER_PROPERTIES_DOC,
50
- changed: writeIfChanged(path.resolve(process.cwd(), USER_PROPERTIES_DOC), renderUserPropertiesDoc(input.users, {
51
- datasources: new Map(input.datasources.map((d) => [d.id, d])),
52
- fileNames: datasourceDocs.fileNames
53
- }))
54
- }]
55
- ],
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
+ }],
56
48
  datasources: input.datasources.map(({ id, name }) => ({
57
49
  id,
58
50
  name,
@@ -18,98 +18,6 @@ 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} `,
37
- `Datasource ID: ${users.datasourceId}` + (registered === void 0 ? "" : ` \nRegistered as: [${datasourceLabel(registered)}](./datasources/${requireDefined(context.fileNames.get(registered.id))}) (${registered.id})`),
38
- "",
39
- "> This document is generated automatically when softr.json's `user.properties` or the users table changes.",
40
- "",
41
- "Each alias below is declared in softr.json under `user.properties`, and read as `user.properties.<alias>`.",
42
- "",
43
- "## Field reference",
44
- "",
45
- ...aliases.flatMap(([alias, field]) => {
46
- const linkedId = option(field.options, "linkedTableId");
47
- const linked = typeof linkedId === "string" ? linkedDatasource(linkedId, context.datasources) : void 0;
48
- return [
49
- `### ${alias}`,
50
- "",
51
- `- Field: ${displayName(field.name)}`,
52
- `- ID: ${field.id}`,
53
- `- Type: ${fieldTypeLiteral(field).slice(1, -1)}`,
54
- ...optionLines(field),
55
- ...choiceLines(field),
56
- ...typeof linkedId !== "string" ? [] : linked === void 0 ? [`- Links to: ${linkedId}, not registered`] : [`- Links to: [${datasourceLabel(linked)}](./datasources/${requireDefined(context.fileNames.get(linked.id))}) (${linked.id})`],
57
- ""
58
- ];
59
- }),
60
- "## Read shape",
61
- "",
62
- "In a component:",
63
- "",
64
- "```tsx",
65
- "import { useCurrentUser } from '@softr/sdk'",
66
- "",
67
- "const user = useCurrentUser()",
68
- "if (user === null) return null",
69
- "",
70
- ...sampled.map(([alias, field]) => `user.properties.${alias} // ${fieldTypeLiteral(field).slice(1, -1)}`),
71
- "```",
72
- "",
73
- "In a route's `beforeLoad`, to send a visitor elsewhere. It guards navigation only, not data:",
74
- "",
75
- "```tsx",
76
- "import { createFileRoute, redirect } from '@tanstack/react-router'",
77
- "import { assertRouteAccess, defineVisibility } from '@softr/sdk'",
78
- "",
79
- "const visibility = defineVisibility({ userGroupTypes: ['LOGGED_IN_USERS'] })",
80
- "",
81
- "export const Route = createFileRoute('/example')({",
82
- " beforeLoad: (opts) => {",
83
- " assertRouteAccess(visibility, opts)",
84
- ` if (opts.context.user.properties.${first} == null) throw redirect({ to: '/' })`,
85
- " },",
86
- "})",
87
- "```",
88
- "",
89
- "In a server function, called from a route's `loader`. The handler checks the visitor itself:",
90
- "",
91
- "```tsx",
92
- "import { createFileRoute } from '@tanstack/react-router'",
93
- "import { createServerFn } from '@tanstack/react-start'",
94
- "",
95
- `const get${pascalCase(first)} = createServerFn().handler(async ({ context }) => {`,
96
- " if (!context.user) throw new Error('Not signed in')",
97
- ` return context.user.properties.${first}`,
98
- "})",
99
- "",
100
- "export const Route = createFileRoute('/example')({",
101
- ` loader: () => get${pascalCase(first)}(),`,
102
- " component: Example,",
103
- "})",
104
- "",
105
- "function Example() {",
106
- ` const ${first} = Route.useLoaderData()`,
107
- " // ...",
108
- "}",
109
- "```",
110
- ""
111
- ].join("\n");
112
- }
113
21
  /** Two trailing spaces are a markdown hard break, which keeps the block one paragraph. */
114
22
  function summary(datasource, context) {
115
23
  const lines = [`Datasource ID: ${datasource.id}`];
@@ -407,4 +315,4 @@ function words(value) {
407
315
  }
408
316
 
409
317
  //#endregion
410
- export { renderDatasourceDoc, renderDatasourceIndex, renderUserPropertiesDoc };
318
+ export { renderDatasourceDoc, renderDatasourceIndex };
@@ -251,21 +251,6 @@ function linkedRecord(options, context) {
251
251
  if (linked === void 0 || linked.primaryFieldId === "") return "LinkedRecord";
252
252
  return `LinkedRecord<NonNullable<${`${nameOf(linked.id, context)}Read[${quote(linked.primaryFieldId)}]`}>>`;
253
253
  }
254
- /** `SoftrUserProperties`, each alias typed as its field reads. */
255
- function renderUserProperties(properties, datasources) {
256
- const context = {
257
- datasources: byId(datasources),
258
- names: typeNames(datasources)
259
- };
260
- return [
261
- "interface SoftrUserProperties {",
262
- ...Object.entries(properties).map(([alias, field]) => indent(readMember({
263
- ...field,
264
- id: alias
265
- }, context))),
266
- "}"
267
- ].join("\n");
268
- }
269
254
  function renderRegistry(datasources, context) {
270
255
  return [
271
256
  "interface SoftrDatasources {",
@@ -509,4 +494,4 @@ function indent(block) {
509
494
  }
510
495
 
511
496
  //#endregion
512
- export { choiceEntries, fieldTypeLiteral, isWritable, linkedDatasource, option, readExample, renderDatasourceTypes, renderUserProperties, resolvedShape, scalarExample, underlyingType };
497
+ export { choiceEntries, fieldTypeLiteral, isWritable, linkedDatasource, option, readExample, renderDatasourceTypes, resolvedShape, scalarExample, underlyingType };