@softr/sdk 0.0.22-alpha.0 → 0.0.22-alpha.2

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.
@@ -8,7 +8,7 @@ import { readSessionToken } from "../auth/session.js";
8
8
  const MAX_RECORD_COUNT = 100;
9
9
  const COUNT_CNT = "COUNT_CNT";
10
10
  const dsUrl = (datasourceId) => {
11
- const datasourceApiUrl = process.env.SOFTR_DATASOURCE_API_URL || "https://datasource-api-staging12.softr.io";
11
+ const datasourceApiUrl = process.env.SOFTR_DATASOURCE_API_URL;
12
12
  if (!datasourceApiUrl) panic("SOFTR_DATASOURCE_API_URL is not set.");
13
13
  const { applicationId } = sessionConfig();
14
14
  return `${withoutTrailingSlash(datasourceApiUrl)}/v1/datasource/vibe/applications/${applicationId}/datasources/${datasourceId}`;
@@ -0,0 +1,141 @@
1
+ import { makeSessionClearingCookies, makeSessionTokenCookie, sessionTokenCookieName } from "../auth/session.js";
2
+
3
+ //#region src/preview-identity/preview-identity.ts
4
+ const PREVIEW_REDEMPTION_PATH = "/__preview/identity";
5
+ const PREVIEW_SIGNED_IN_PATH = "/__preview/signed-in";
6
+ /**
7
+ * Signs the preview in as one of the app's own users by spending the one-time grant studio-api put
8
+ * in this URL. No cookie of its own is forwarded: the grant is the only credential, and studio-api
9
+ * authorized the builder before it issued one.
10
+ */
11
+ const redeemGrant = async (req, res) => {
12
+ res.setHeader("Cache-Control", "no-store");
13
+ res.setHeader("Referrer-Policy", "no-referrer");
14
+ if (req.method !== "GET") {
15
+ sendJson(res, 405, { error: `${PREVIEW_REDEMPTION_PATH} only supports GET` });
16
+ return;
17
+ }
18
+ const grant = searchParamsOf(req).get("grant");
19
+ if (!grant) {
20
+ sendJson(res, 400, { error: "no grant" });
21
+ return;
22
+ }
23
+ const landing = landingPath(searchParamsOf(req).get("next"));
24
+ let session;
25
+ try {
26
+ session = await redeemPreviewGrant(req, grant);
27
+ } catch (error) {
28
+ sendJson(res, statusOf(error), { error: messageOf(error) });
29
+ return;
30
+ }
31
+ const secure = requestIsHttps(req);
32
+ res.statusCode = 302;
33
+ res.setHeader("Location", landing);
34
+ res.setHeader("Set-Cookie", session.anonymous ? makeSessionClearingCookies(secure) : makeSessionTokenCookie(session.jwt, secure));
35
+ res.end();
36
+ };
37
+ /**
38
+ * Whether the preview currently holds a session, and nothing else.
39
+ *
40
+ * Presence only, never decoded: whether anyone is signed in is all Studio's picker needs, and
41
+ * decoding would put an app user's identity on a route the studio bundle can read. Asked under the
42
+ * one name this scheme's app would read, so a cookie the app ignores reads as signed out here too
43
+ * rather than as a session it cannot see.
44
+ */
45
+ const reportSignedIn = (req, res) => {
46
+ res.setHeader("Cache-Control", "no-store");
47
+ if (req.method !== "GET") {
48
+ sendJson(res, 405, { error: `${PREVIEW_SIGNED_IN_PATH} only supports GET` });
49
+ return;
50
+ }
51
+ sendJson(res, 200, { signedIn: hasSessionCookie(req.headers.cookie, sessionTokenCookieName(requestIsHttps(req))) });
52
+ };
53
+ var RedemptionError = class extends Error {
54
+ status;
55
+ constructor(status, message) {
56
+ super(message);
57
+ this.status = status;
58
+ }
59
+ };
60
+ function statusOf(error) {
61
+ return error instanceof RedemptionError ? error.status : 503;
62
+ }
63
+ function messageOf(error) {
64
+ return error instanceof Error ? error.message : String(error);
65
+ }
66
+ async function redeemPreviewGrant(req, grant) {
67
+ const applicationId = process.env.SOFTR_APPLICATION_ID;
68
+ const studioApiUrl = process.env.SOFTR_STUDIO_API_URL?.trim() || studioApiUrlFor(req);
69
+ if (!applicationId || !studioApiUrl) throw new RedemptionError(503, "studio-api is unavailable");
70
+ let response;
71
+ try {
72
+ response = await fetch(`${studioApiUrl}/v1/applications/${encodeURIComponent(applicationId)}/vibe-coding/preview-identity/redeem`, {
73
+ method: "POST",
74
+ headers: { "content-type": "application/json" },
75
+ body: JSON.stringify({ grant })
76
+ });
77
+ } catch {
78
+ throw new RedemptionError(503, "studio-api is unreachable");
79
+ }
80
+ if (!response.ok) throw new RedemptionError(response.status, "studio-api refused the preview identity grant");
81
+ const body = await response.json().catch(() => null);
82
+ if (typeof body !== "object" || body === null) throw new RedemptionError(503, "studio-api returned no session");
83
+ const { anonymous, jwt } = body;
84
+ if (anonymous === true) return { anonymous: true };
85
+ if (typeof jwt !== "string" || jwt.length === 0) throw new RedemptionError(503, "studio-api returned no token");
86
+ return {
87
+ anonymous: false,
88
+ jwt
89
+ };
90
+ }
91
+ const STAGING_HOST_PATTERN = /(?:^|\.)staging(\d+)\.softr\.io$/;
92
+ /**
93
+ * The studio-api base URL of the environment this dev server runs in, read off the request's host.
94
+ *
95
+ * The host is the only per-environment signal the app has: it is served at
96
+ * `<slug>-app.vibe-coding-preview.staging12.softr.io`, and staging 12's API is
97
+ * `studio-api-staging12.softr.io`. Every other host gives `null` — localhost included.
98
+ */
99
+ function studioApiUrlFor(req) {
100
+ const forwarded = req.headers["x-forwarded-host"];
101
+ const name = ((Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? req.headers.host ?? "").split(",")[0].trim().split(":")[0];
102
+ const digits = STAGING_HOST_PATTERN.exec(name)?.[1];
103
+ if (!digits) return null;
104
+ return `https://studio-api-staging${digits.padStart(2, "0")}.softr.io`;
105
+ }
106
+ function hasSessionCookie(header, name) {
107
+ if (!header) return false;
108
+ return header.split(";").some((pair) => {
109
+ const separator = pair.indexOf("=");
110
+ return separator !== -1 && pair.slice(0, separator).trim() === name;
111
+ });
112
+ }
113
+ /**
114
+ * Whether this request is https, by the same rule the session code reads the cookie back with.
115
+ *
116
+ * h3's `getRequestProtocol` compares `x-forwarded-proto` for exact equality, so it reads anything
117
+ * else — `https, http` from a two-hop chain, or `HTTPS` — as not-https. Being more generous here
118
+ * than it is writes `__Host-session_token` where the app then looks up `session_token`, and the
119
+ * visitor is silently anonymous with no error on either side.
120
+ */
121
+ function requestIsHttps(req) {
122
+ const forwarded = req.headers["x-forwarded-proto"];
123
+ if (forwarded === "https") return true;
124
+ if (forwarded === "http") return false;
125
+ return Boolean(req.socket?.encrypted);
126
+ }
127
+ function landingPath(next) {
128
+ if (!next || !next.startsWith("/") || next.startsWith("//") || next.includes("\\") || next.includes("://") || /[\u0000-\u001f\u007f]/.test(next)) return "/";
129
+ return next;
130
+ }
131
+ function searchParamsOf(req) {
132
+ return new URL(req.url ?? "", "http://request.invalid").searchParams;
133
+ }
134
+ function sendJson(res, status, body) {
135
+ res.statusCode = status;
136
+ res.setHeader("Content-Type", "application/json");
137
+ res.end(JSON.stringify(body));
138
+ }
139
+
140
+ //#endregion
141
+ export { PREVIEW_REDEMPTION_PATH, PREVIEW_SIGNED_IN_PATH, redeemGrant, reportSignedIn };
@@ -2,8 +2,13 @@ import "@tanstack/router-generator";
2
2
 
3
3
  //#region src/routes/discover.d.ts
4
4
 
5
+ type DatasourceMethod = "list" | "get" | "create" | "update" | "delete" | "metric" | "chartData";
6
+ type DatasourceAccess = {
7
+ id: string;
8
+ methods: Array<DatasourceMethod>;
9
+ };
5
10
  type VisibilityValue = string | Array<VisibilityValue> | {
6
11
  [key: string]: VisibilityValue;
7
12
  };
8
13
  //#endregion
9
- export { VisibilityValue };
14
+ export { DatasourceAccess, VisibilityValue };
@@ -65,6 +65,42 @@ function extractVisibility(source, filePath) {
65
65
  }
66
66
  };
67
67
  }
68
+ /** Reads all ds.method() calls from the source text. The route file is never executed. */
69
+ function extractDatasources(source, filePath) {
70
+ let program;
71
+ try {
72
+ program = parse(source, {
73
+ sourceType: "module",
74
+ plugins: ["typescript", "jsx"]
75
+ });
76
+ } catch (error) {
77
+ const position = error.loc;
78
+ return {
79
+ ok: false,
80
+ error: {
81
+ code: "unparsable",
82
+ message: `could not parse ${filePath}: ${error.message}`,
83
+ filePath,
84
+ line: position?.line ?? 1,
85
+ column: position?.column ?? 0
86
+ }
87
+ };
88
+ }
89
+ const raw = findDatasourceCalls(program, filePath);
90
+ if (!raw.ok) return raw;
91
+ const byId = /* @__PURE__ */ new Map();
92
+ for (const { id, method } of raw.calls) {
93
+ if (!byId.has(id)) byId.set(id, /* @__PURE__ */ new Set());
94
+ byId.get(id).add(method);
95
+ }
96
+ return {
97
+ ok: true,
98
+ datasources: Array.from(byId.entries()).map(([id, methods]) => ({
99
+ id,
100
+ methods: [...methods]
101
+ }))
102
+ };
103
+ }
68
104
  /**
69
105
  * The generator `mkdirSync`s `tmpDir` and the directory of `generatedRouteTree` outside the `fs`
70
106
  * shim, so defaulted they create directories in the crawled project. Aimed at `os.tmpdir()` both
@@ -74,7 +110,18 @@ function extractVisibility(source, filePath) {
74
110
  const TMP_DIR = os.tmpdir();
75
111
  const generatedRouteTreePath = () => path.join(TMP_DIR, `softr-sdk-route-tree-${randomUUID()}.gen.ts`);
76
112
  const SDK_PACKAGE_NAME = "@softr/sdk";
113
+ const SDK_SERVER_PACKAGE_NAME = "@softr/sdk/server";
77
114
  const VISIBILITY_FUNCTION = "defineVisibility";
115
+ const DS_BINDING = "ds";
116
+ const DS_METHODS = new Set([
117
+ "list",
118
+ "get",
119
+ "create",
120
+ "update",
121
+ "delete",
122
+ "metric",
123
+ "chartData"
124
+ ]);
78
125
  function fail(code, message, filePath, line, column) {
79
126
  return {
80
127
  ok: false,
@@ -87,6 +134,66 @@ function fail(code, message, filePath, line, column) {
87
134
  }
88
135
  };
89
136
  }
137
+ function findDatasourceCalls(ast, filePath) {
138
+ const calls = [];
139
+ let error = null;
140
+ traverse(ast, { CallExpression(path$1) {
141
+ const { node } = path$1;
142
+ const { callee } = node;
143
+ if (callee.type !== "MemberExpression") return;
144
+ if (callee.computed) return;
145
+ if (callee.object.type !== "Identifier") return;
146
+ if (callee.property.type !== "Identifier") return;
147
+ const method = callee.property.name;
148
+ if (!DS_METHODS.has(method)) return;
149
+ if (!isDsImport(path$1, callee.object.name)) return;
150
+ const firstArg = node.arguments[0];
151
+ if (firstArg === void 0 || firstArg.type === "SpreadElement") {
152
+ error = {
153
+ code: "not-literal",
154
+ message: `${filePath}: ds.${method}() must be called with a string literal datasource id`,
155
+ filePath,
156
+ line: node.loc?.start.line ?? 1,
157
+ column: node.loc?.start.column ?? 0
158
+ };
159
+ path$1.stop();
160
+ return;
161
+ }
162
+ if (firstArg.type !== "StringLiteral") {
163
+ error = {
164
+ code: "not-literal",
165
+ message: `${filePath}: the datasource id passed to ds.${method}() must be a string literal`,
166
+ filePath,
167
+ line: firstArg.loc?.start.line ?? 1,
168
+ column: firstArg.loc?.start.column ?? 0
169
+ };
170
+ path$1.stop();
171
+ return;
172
+ }
173
+ calls.push({
174
+ id: firstArg.value,
175
+ method
176
+ });
177
+ } });
178
+ if (error !== null) return {
179
+ ok: false,
180
+ error
181
+ };
182
+ return {
183
+ ok: true,
184
+ calls
185
+ };
186
+ }
187
+ function isDsImport(path$1, name) {
188
+ const binding = path$1.scope.getBinding(name);
189
+ if (binding === void 0 || binding.kind !== "module") return false;
190
+ const specifier = binding.path.node;
191
+ const declaration = binding.path.parent;
192
+ if (specifier.type !== "ImportSpecifier" || declaration.type !== "ImportDeclaration") return false;
193
+ if (specifier.importKind === "type" || declaration.importKind === "type") return false;
194
+ if (declaration.source.value !== SDK_SERVER_PACKAGE_NAME) return false;
195
+ return (specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value) === DS_BINDING;
196
+ }
90
197
  const traverse = babelTraverse.default ?? babelTraverse;
91
198
  /** Only a call initialising a top-level variable counts; one inside a function or block does not. */
92
199
  function findVisibilityCalls(ast) {
@@ -195,4 +302,4 @@ function readLiteral(input) {
195
302
  }
196
303
 
197
304
  //#endregion
198
- export { crawlRoutes, extractVisibility };
305
+ export { crawlRoutes, extractDatasources, extractVisibility };
@@ -1,6 +1,12 @@
1
- import { VisibilityValue } from "./discover.js";
1
+ import { DatasourceAccess, VisibilityValue } from "./discover.js";
2
2
 
3
3
  //#region src/routes/manifest.d.ts
4
+ type DatasourceEntry = DatasourceAccess & {
5
+ /** Project-relative path to the file where the datasource is accessed. */
6
+ filename: string;
7
+ /** Route path, if the file is a route file. */
8
+ route?: string;
9
+ };
4
10
  type ManifestNode = {
5
11
  routePath: string;
6
12
  filePath: string;
@@ -12,6 +18,7 @@ type ManifestNode = {
12
18
  };
13
19
  type Manifest = {
14
20
  root: ManifestNode;
21
+ datasources: Array<DatasourceEntry>;
15
22
  };
16
23
  //#endregion
17
24
  export { Manifest, ManifestNode };
@@ -1,4 +1,4 @@
1
- import { crawlRoutes, extractVisibility } from "./discover.js";
1
+ import { crawlRoutes, extractDatasources, extractVisibility } from "./discover.js";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { inferFullPath } from "@tanstack/router-generator";
@@ -33,11 +33,13 @@ async function generateRoutesManifest(root) {
33
33
  }
34
34
  function buildManifest(opts) {
35
35
  const routesDirectory = opts.crawl.routesDirectory;
36
+ const datasources = [];
36
37
  const toNode = (node) => {
37
38
  const projectRelative = toProjectRelative(opts.root, routesDirectory, node.filePath);
38
39
  const routePath = node.routePath;
39
40
  if (routePath === void 0) throw new Error(`the route crawl produced a node with no route path: ${projectRelative}`);
40
41
  let declared = visibilityOf(opts, node.filePath);
42
+ datasources.push(...datasourceEntriesOf(opts, node.filePath, routePath));
41
43
  const lazyPiece = opts.crawl.routePiecesByPath[routePath]?.lazy;
42
44
  if (lazyPiece && lazyPiece.filePath !== node.filePath) {
43
45
  const lazyDeclared = visibilityOf(opts, lazyPiece.filePath);
@@ -49,6 +51,7 @@ function buildManifest(opts) {
49
51
  column: lazyDeclared.column
50
52
  });
51
53
  if (lazyDeclared !== null) declared = lazyDeclared;
54
+ datasources.push(...datasourceEntriesOf(opts, lazyPiece.filePath, routePath));
52
55
  }
53
56
  return {
54
57
  routePath,
@@ -70,14 +73,18 @@ function buildManifest(opts) {
70
73
  line: rootDeclared.line,
71
74
  column: rootDeclared.column
72
75
  });
73
- return { root: {
74
- routePath: ROOT_ROUTE_ID,
75
- filePath: rootRelative,
76
- inferFullPath: inferFullPath(rootNode) ?? null,
77
- createFileRouteProps: [...rootNode.createFileRouteProps ?? []].sort(),
78
- visibility: null,
79
- children: opts.crawl.routeTree.map(toNode)
80
- } };
76
+ datasources.push(...datasourceEntriesOf(opts, rootNode.filePath, ROOT_ROUTE_ID));
77
+ return {
78
+ root: {
79
+ routePath: ROOT_ROUTE_ID,
80
+ filePath: rootRelative,
81
+ inferFullPath: inferFullPath(rootNode) ?? null,
82
+ createFileRouteProps: [...rootNode.createFileRouteProps ?? []].sort(),
83
+ visibility: null,
84
+ children: opts.crawl.routeTree.map(toNode)
85
+ },
86
+ datasources
87
+ };
81
88
  }
82
89
  /**
83
90
  * `rootRouteId` from `@tanstack/router-core`, which is what a consumer matches against — not the
@@ -131,6 +138,19 @@ function visibilityOf(opts, filePath) {
131
138
  checkVisibilityShape(declared);
132
139
  return declared;
133
140
  }
141
+ function datasourceEntriesOf(opts, filePath, routePath) {
142
+ const projectRelative = toProjectRelative(opts.root, opts.crawl.routesDirectory, filePath);
143
+ const source = opts.sources.get(path.resolve(opts.root, opts.crawl.routesDirectory, filePath));
144
+ if (source === void 0) return [];
145
+ const result = extractDatasources(source, projectRelative);
146
+ if (!result.ok) throw new ManifestError(result.error);
147
+ return result.datasources.map(({ id, methods }) => ({
148
+ id,
149
+ methods,
150
+ filename: projectRelative,
151
+ route: routePath
152
+ }));
153
+ }
134
154
  /**
135
155
  * Only ENOENT becomes `null`. The caller reads `null` as "nothing declared here", so swallowing any
136
156
  * other read error would turn an unreadable declaration into an ungated route.
package/dist/server.d.ts CHANGED
@@ -2,6 +2,5 @@ import { auth } from "./auth/server.js";
2
2
  import { ds } from "./core/datasource.js";
3
3
  import { workflow } from "./core/workflow.js";
4
4
  import { integration } from "./core/integration.js";
5
- import { makeSessionClearingCookies, makeSessionTokenCookie, sessionTokenCookieName } from "./auth/session.js";
6
5
  import "@tanstack/react-start/server-only";
7
- export { auth, ds, integration, makeSessionClearingCookies, makeSessionTokenCookie, sessionTokenCookieName, workflow };
6
+ export { auth, ds, integration, workflow };
package/dist/server.js CHANGED
@@ -1,8 +1,7 @@
1
- import { makeSessionClearingCookies, makeSessionTokenCookie, sessionTokenCookieName } from "./auth/session.js";
2
1
  import { auth } from "./auth/server.js";
3
2
  import { ds } from "./core/datasource.js";
4
3
  import { workflow } from "./core/workflow.js";
5
4
  import { integration } from "./core/integration.js";
6
5
  import "@tanstack/react-start/server-only";
7
6
 
8
- export { auth, ds, integration, makeSessionClearingCookies, makeSessionTokenCookie, sessionTokenCookieName, workflow };
7
+ export { auth, ds, integration, workflow };
package/dist/vite.js CHANGED
@@ -31,7 +31,34 @@ async function generateManifest(root) {
31
31
  * edit engine when the pod supplies one.
32
32
  */
33
33
  function softr() {
34
- return [routesManifest(), editEngine()];
34
+ return [
35
+ routesManifest(),
36
+ previewIdentity(),
37
+ editEngine()
38
+ ];
39
+ }
40
+ /**
41
+ * The routes that sign the preview in as one of the app's own users, and report whether it is.
42
+ *
43
+ * They live in this package, not in the edit engine, because signing the preview in means writing
44
+ * the very cookie this app's own session code reads back — one implementation of the cookie format,
45
+ * with no version to keep in step across a package seam.
46
+ *
47
+ * `SOFTR_APPLICATION_ID` is the gate: the pod sets it, and it is what binds a redemption to the
48
+ * application the grant was issued for. An app running on a developer's machine has none, and
49
+ * serves neither route.
50
+ */
51
+ async function previewIdentity() {
52
+ if (!process.env.SOFTR_APPLICATION_ID) return false;
53
+ const { PREVIEW_REDEMPTION_PATH, PREVIEW_SIGNED_IN_PATH, redeemGrant, reportSignedIn } = await import("./preview-identity/preview-identity.js");
54
+ return {
55
+ name: "softr-preview-identity",
56
+ apply: "serve",
57
+ configureServer(server) {
58
+ server.middlewares.use(PREVIEW_REDEMPTION_PATH, redeemGrant);
59
+ server.middlewares.use(PREVIEW_SIGNED_IN_PATH, reportSignedIn);
60
+ }
61
+ };
35
62
  }
36
63
  /** Plugin order does not matter: this puts nothing into the module graph. */
37
64
  function routesManifest() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.22-alpha.0",
3
+ "version": "0.0.22-alpha.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: core
12
12
  library: softr-sdk
13
- library_version: 0.0.21
13
+ library_version: 0.0.22-alpha.2
14
14
  ---
15
15
 
16
16
  # Softr SDK core
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.21
13
+ library_version: 0.0.22-alpha.2
14
14
  ---
15
15
 
16
16
  This skill builds on softr-sdk. Read it first for the entrypoints and the client/server boundary.
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.21
13
+ library_version: 0.0.22-alpha.2
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.
@@ -8,7 +8,7 @@ description: >-
8
8
  metadata:
9
9
  type: sub-skill
10
10
  library: softr-sdk
11
- library_version: 0.0.21
11
+ library_version: 0.0.22-alpha.2
12
12
  ---
13
13
 
14
14
  This skill builds on softr-sdk. Read it first for the entrypoints and the client/server boundary.
@@ -24,15 +24,13 @@ 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'
31
27
  ```
32
28
 
33
29
  Pass a zod schema straight to `.validator`. Start accepts it and types `data` from it.
34
30
 
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.
31
+ Every `ds` call names its datasource by 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.
32
+
33
+ **The datasource id must be a string literal at the call site.**
36
34
 
37
35
  ## Core Patterns
38
36
 
@@ -44,7 +42,7 @@ const NewProject = z.object({ name: z.string(), status: z.string() })
44
42
  const createProject = createServerFn({ method: 'POST' })
45
43
  .validator(NewProject)
46
44
  .handler(async ({ data }) =>
47
- ds.create(PROJECTS, {
45
+ ds.create('8ffb4814-b028-4496-b679-7face2167ebc', {
48
46
  data,
49
47
  select: q.select({ name: 'fZk3Qm9pLw2Xa1', status: 'Ru7bVx2cNq0Ed9' }),
50
48
  }),
@@ -65,7 +63,7 @@ const Rename = z.object({ id: z.string(), name: z.string() })
65
63
  const renameProject = createServerFn({ method: 'POST' })
66
64
  .validator(Rename)
67
65
  .handler(async ({ data }) =>
68
- ds.update(PROJECTS, {
66
+ ds.update('8ffb4814-b028-4496-b679-7face2167ebc', {
69
67
  id: data.id,
70
68
  data: { name: data.name },
71
69
  select: q.select({ name: 'fZk3Qm9pLw2Xa1' }),
@@ -84,7 +82,7 @@ const RecordId = z.object({ id: z.string() })
84
82
 
85
83
  const deleteProject = createServerFn({ method: 'POST' })
86
84
  .validator(RecordId)
87
- .handler(async ({ data }) => ds.delete(PROJECTS, data.id))
85
+ .handler(async ({ data }) => ds.delete('8ffb4814-b028-4496-b679-7face2167ebc', data.id))
88
86
  ```
89
87
 
90
88
  **`delete` takes the record id as a positional argument**, unlike `create` and `update`, which take an options object. It resolves to nothing.
@@ -103,6 +101,8 @@ Stamping a record with who created it, or scoping an update to the person making
103
101
 
104
102
  **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
103
 
104
+ **A datasource id stored in a variable.** The build reads ids statically to build the route manifest; it cannot resolve a name. Pass the UUID as a string literal directly at the call site. The lint rule `@softr/ds-no-computed-id` catches this at development time.
105
+
106
106
  **A write that sends `fields`.** The option is `data`. Only typecheck catches this, so it survives any run that does not typecheck.
107
107
 
108
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.21
13
+ library_version: 0.0.22-alpha.2
14
14
  ---
15
15
 
16
16
  This skill builds on softr-sdk. Read it first for the entrypoints and the client/server boundary.
@@ -25,15 +25,22 @@ 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'
32
28
  ```
33
29
 
34
30
  `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.
35
31
 
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.
32
+ Every `ds` call names its datasource by 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.
33
+
34
+ **The datasource id must be a string literal at the call site.** Do not assign it to a variable and pass the variable — the build reads ids statically and cannot resolve a name. Write the UUID directly as the first argument:
35
+
36
+ ```ts
37
+ // correct
38
+ ds.list('8ffb4814-b028-4496-b679-7face2167ebc', { ... })
39
+
40
+ // wrong — the build cannot read this
41
+ const POSTS = '8ffb4814-b028-4496-b679-7face2167ebc'
42
+ ds.list(POSTS, { ... })
43
+ ```
37
44
 
38
45
  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
46
 
@@ -59,10 +66,8 @@ Cast at the `ds.list` call using the `ListResult` generic so the type flows thro
59
66
  ```ts
60
67
  import type { ListResult, SoftrRecord } from '@softr/sdk'
61
68
 
62
- const PRODUCTS = '3c1e8a52-6f0d-4b7e-9a21-5d84c0f7b2e6' // from the table's registration
63
-
64
69
  const fetchProducts = createServerFn().handler(() =>
65
- ds.list(PRODUCTS, { select, limit: 100 }) as Promise<ListResult<Product>>
70
+ ds.list('3c1e8a52-6f0d-4b7e-9a21-5d84c0f7b2e6', { select, limit: 100 }) as Promise<ListResult<Product>>
66
71
  )
67
72
  ```
68
73
 
@@ -74,7 +79,7 @@ const fetchProducts = createServerFn().handler(() =>
74
79
 
75
80
  ```ts
76
81
  const listPublishedPosts = createServerFn().handler(async () => {
77
- return await ds.list(POSTS, {
82
+ return await ds.list('8ffb4814-b028-4496-b679-7face2167ebc', {
78
83
  select: q.select({
79
84
  title: 'fZk3Qm9pLw2Xa1',
80
85
  author: 'mB4kQ8yTn1sWc7',
@@ -99,7 +104,7 @@ Softr field ids are opaque strings (`fZk3Qm9pLw2Xa1`), copied from the table's s
99
104
  ```ts
100
105
  const select = q.select({ title: 'fZk3Qm9pLw2Xa1', views: 'Ru7bVx2cNq0Ed9' })
101
106
 
102
- await ds.list(POSTS, {
107
+ await ds.list('8ffb4814-b028-4496-b679-7face2167ebc', {
103
108
  select,
104
109
  where: q.number('views').gt(100), // alias, not Ru7bVx2cNq0Ed9
105
110
  orderBy: q.asc('title'), // alias here too
@@ -112,7 +117,7 @@ Once `select` is present it is the whole vocabulary. The returned record carries
112
117
  ### Get one record
113
118
 
114
119
  ```ts
115
- const post = await ds.get(POSTS, { id: postId })
120
+ const post = await ds.get('8ffb4814-b028-4496-b679-7face2167ebc', { id: postId })
116
121
  if (post === null) throw notFound()
117
122
  ```
118
123
 
@@ -138,9 +143,9 @@ Five field types, `q.text`, `q.number`, `q.boolean`, `q.date` and `q.array`, com
138
143
  ### Paginate
139
144
 
140
145
  ```ts
141
- const first = await ds.list(POSTS, { limit: 50 })
146
+ const first = await ds.list('8ffb4814-b028-4496-b679-7face2167ebc', { limit: 50 })
142
147
  if (first.nextOffset !== null) {
143
- const second = await ds.list(POSTS, { limit: 50, offset: first.nextOffset })
148
+ const second = await ds.list('8ffb4814-b028-4496-b679-7face2167ebc', { limit: 50, offset: first.nextOffset })
144
149
  }
145
150
  ```
146
151
 
@@ -149,12 +154,10 @@ if (first.nextOffset !== null) {
149
154
  ### Scope the read to the visitor
150
155
 
151
156
  ```ts
152
- const INVOICES = 'e4b7c2a1-9d3f-4e68-b05a-7f1c6d2e8a93' // from the table's registration
153
-
154
157
  const listMyInvoices = createServerFn().handler(async ({ context }) => {
155
158
  if (!context.user) throw new Error('Not signed in')
156
159
 
157
- return await ds.list(INVOICES, {
160
+ return await ds.list('e4b7c2a1-9d3f-4e68-b05a-7f1c6d2e8a93', {
158
161
  select: q.select({ owner: 'Gq5wNt8rJy3Hb1', amount: 'Vk9cXm2pSz6Ld4' }),
159
162
  where: q.text('owner').is(context.user.id),
160
163
  })
@@ -12,7 +12,7 @@ description: >-
12
12
  metadata:
13
13
  type: sub-skill
14
14
  library: softr-sdk
15
- library_version: 0.0.21
15
+ library_version: 0.0.22-alpha.2
16
16
  ---
17
17
 
18
18
  This skill builds on softr-sdk. Read it first for the entrypoints and the wiring that puts a visitor on route context.
@@ -1,16 +0,0 @@
1
- import "./context.js";
2
-
3
- //#region src/auth/session.d.ts
4
-
5
- declare function makeSessionTokenCookie(token: string, secure?: boolean): string;
6
- /** The clearing pair, for the same out-of-context caller as `makeSessionTokenCookie`. */
7
- declare function makeSessionClearingCookies(secure: boolean): Array<string>;
8
- /**
9
- * The name `readSessionToken` would look the token up under on a request of this
10
- * scheme, for a caller that only needs to know whether one is present and cannot
11
- * decode it. Same `secure` argument as `makeSessionTokenCookie`, so a caller that
12
- * writes and a caller that probes cannot disagree about the name.
13
- */
14
- declare function sessionTokenCookieName(secure: boolean): string;
15
- //#endregion
16
- export { makeSessionClearingCookies, makeSessionTokenCookie, sessionTokenCookieName };