@softr/sdk 0.0.32 → 0.0.35-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 (40) hide show
  1. package/dist/action.d.ts +4 -4
  2. package/dist/action.js +2 -2
  3. package/dist/auth/isomorphic.js +2 -2
  4. package/dist/auth/methods/authenticator.js +1 -1
  5. package/dist/auth/methods/code.js +1 -1
  6. package/dist/auth/methods/magicLink.js +1 -1
  7. package/dist/auth/methods/password.js +1 -1
  8. package/dist/auth/methods/signup.js +1 -1
  9. package/dist/auth/methods/social.js +1 -1
  10. package/dist/auth/platform.js +1 -1
  11. package/dist/auth/session.js +2 -2
  12. package/dist/boot-error.js +1 -1
  13. package/dist/client.d.ts +3 -1
  14. package/dist/client.js +2 -1
  15. package/dist/core/file-upload.d.ts +16 -0
  16. package/dist/core/file-upload.js +34 -0
  17. package/dist/core/integration.js +1 -1
  18. package/dist/core/upload-fn.js +16 -0
  19. package/dist/core/use-file-upload.d.ts +28 -0
  20. package/dist/core/use-file-upload.js +105 -0
  21. package/dist/core/workflow.js +1 -1
  22. package/dist/datasource/chart.js +6 -6
  23. package/dist/datasource/record.js +19 -19
  24. package/dist/index.d.ts +2 -1
  25. package/dist/package.js +1 -1
  26. package/dist/server.d.ts +2 -1
  27. package/dist/server.js +2 -1
  28. package/dist/vite.d.ts +1 -1
  29. package/dist/vite.js +1 -1
  30. package/package.json +1 -1
  31. package/skills/softr-sdk/SKILL.md +1 -1
  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/current-user/SKILL.md +1 -1
  35. package/skills/softr-sdk/file-uploads/SKILL.md +153 -0
  36. package/skills/softr-sdk/integrations/SKILL.md +5 -5
  37. package/skills/softr-sdk/record-mutations/SKILL.md +1 -1
  38. package/skills/softr-sdk/record-queries/SKILL.md +1 -1
  39. package/skills/softr-sdk/routes-and-visibility/SKILL.md +1 -1
  40. package/skills/softr-sdk/workflows/SKILL.md +1 -1
package/dist/action.d.ts CHANGED
@@ -200,25 +200,25 @@ type WorkflowDefinition<TId extends string> = {
200
200
  declare function defineWorkflow<const TId extends string>(definition: WorkflowDefinition<TId>): WorkflowAction<TId>;
201
201
  type IntegrationResource = {
202
202
  type: "INTEGRATION";
203
- datasourceId: string;
203
+ integrationId: string;
204
204
  };
205
205
  type IntegrationAction<TId extends string = string> = Action<IntegrationResource> & {
206
206
  id: TId;
207
207
  };
208
208
  type IntegrationDefinition<TId extends string> = {
209
209
  id: TId;
210
- datasourceId: string;
210
+ integrationId: string;
211
211
  permissions: ActionPermissions;
212
212
  };
213
213
  /**
214
- * Declares an integration: which connected data source to call through, and who may call it.
214
+ * Declares an integration: which workspace integration to call through, and who may call it.
215
215
  *
216
216
  * Pass the returned object to `integration.createFetcher()` to get a bound fetcher.
217
217
  *
218
218
  * @example
219
219
  * const googleCalendar = defineIntegration({
220
220
  * id: "googleCalendar",
221
- * datasourceId: "25d60213-ef4f-4fa0-b094-a46931b2d120",
221
+ * integrationId: "25d60213-ef4f-4fa0-b094-a46931b2d120",
222
222
  * permissions: { userGroup: "LOGGED_IN_USERS" },
223
223
  * })
224
224
  *
package/dist/action.js CHANGED
@@ -38,12 +38,12 @@ function defineWorkflow(definition) {
38
38
  };
39
39
  }
40
40
  function defineIntegration(definition) {
41
- const { id, datasourceId, permissions } = definition;
41
+ const { id, integrationId, permissions } = definition;
42
42
  return {
43
43
  id,
44
44
  resource: {
45
45
  type: "INTEGRATION",
46
- datasourceId
46
+ integrationId
47
47
  },
48
48
  permissions
49
49
  };
@@ -1,11 +1,11 @@
1
1
  import { assert, panic, requireDefined } from "../assertions.js";
2
2
  import { writeCookies } from "../cookies.js";
3
3
  import { sessionConfig } from "./config.js";
4
+ import { clearSession, makeSessionDataCookie, readSessionData, readSessionToken } from "./session.js";
4
5
  import { makePlatform } from "./platform.js";
5
6
  import { bootErrorMiddleware } from "../boot-error.js";
6
- import { clearSession, makeSessionDataCookie, readSessionData, readSessionToken } from "./session.js";
7
- import { rootRouteId, useRouteContext } from "@tanstack/react-router";
8
7
  import { createMiddleware, createServerFn, getGlobalStartContext } from "@tanstack/react-start";
8
+ import { rootRouteId, useRouteContext } from "@tanstack/react-router";
9
9
 
10
10
  //#region src/auth/isomorphic.ts
11
11
  /**
@@ -1,8 +1,8 @@
1
1
  import { assert, panic } from "../../assertions.js";
2
2
  import { writeCookies } from "../../cookies.js";
3
3
  import { sessionConfig } from "../config.js";
4
- import { makePlatform } from "../platform.js";
5
4
  import { clearPendingAuth, makeSessionDataCookie, makeSessionTokenCookie, readPendingAuthToken, readSessionToken } from "../session.js";
5
+ import { makePlatform } from "../platform.js";
6
6
  import { authError } from "./utils.js";
7
7
 
8
8
  //#region src/auth/methods/authenticator.ts
@@ -1,8 +1,8 @@
1
1
  import { assert, panic } from "../../assertions.js";
2
2
  import { writeCookies } from "../../cookies.js";
3
3
  import { sessionConfig } from "../config.js";
4
- import { makePlatform } from "../platform.js";
5
4
  import { clearPendingAuth, makePendingAuthCookie, makeSessionDataCookie, makeSessionTokenCookie } from "../session.js";
5
+ import { makePlatform } from "../platform.js";
6
6
  import { authError } from "./utils.js";
7
7
 
8
8
  //#region src/auth/methods/code.ts
@@ -1,8 +1,8 @@
1
1
  import { assert, panic } from "../../assertions.js";
2
2
  import { writeCookies } from "../../cookies.js";
3
3
  import { sessionConfig } from "../config.js";
4
- import { makePlatform } from "../platform.js";
5
4
  import { makePendingAuthCookie, makeSessionDataCookie, makeSessionTokenCookie } from "../session.js";
5
+ import { makePlatform } from "../platform.js";
6
6
  import { authError } from "./utils.js";
7
7
 
8
8
  //#region src/auth/methods/magicLink.ts
@@ -1,8 +1,8 @@
1
1
  import { assert, panic, softAssert } from "../../assertions.js";
2
2
  import { writeCookies } from "../../cookies.js";
3
3
  import { sessionConfig } from "../config.js";
4
- import { makePlatform } from "../platform.js";
5
4
  import { clearPendingAuth, clearSession, makePendingAuthCookie, makeSessionDataCookie, makeSessionTokenCookie, readSessionToken } from "../session.js";
5
+ import { makePlatform } from "../platform.js";
6
6
  import { authError } from "./utils.js";
7
7
 
8
8
  //#region src/auth/methods/password.ts
@@ -1,8 +1,8 @@
1
1
  import { assert, panic } from "../../assertions.js";
2
2
  import { writeCookies } from "../../cookies.js";
3
3
  import { sessionConfig } from "../config.js";
4
- import { makePlatform } from "../platform.js";
5
4
  import { clearPendingAuth, makePendingAuthCookie, makeSessionDataCookie, makeSessionTokenCookie } from "../session.js";
5
+ import { makePlatform } from "../platform.js";
6
6
  import { authError } from "./utils.js";
7
7
 
8
8
  //#region src/auth/methods/signup.ts
@@ -1,8 +1,8 @@
1
1
  import { assert, panic } from "../../assertions.js";
2
2
  import { writeCookies } from "../../cookies.js";
3
3
  import { sessionConfig } from "../config.js";
4
- import { makePlatform } from "../platform.js";
5
4
  import { clearPendingAuth, makePendingAuthCookie, makeSessionDataCookie, makeSessionTokenCookie } from "../session.js";
5
+ import { makePlatform } from "../platform.js";
6
6
  import { authError } from "./utils.js";
7
7
 
8
8
  //#region src/auth/methods/social.ts
@@ -1,8 +1,8 @@
1
1
  import { fetch } from "../fetch.js";
2
2
  import { panic } from "../assertions.js";
3
+ import { permissionsSchema } from "./permissions.js";
3
4
  import { sessionConfig } from "./config.js";
4
5
  import { userSchema } from "./user.js";
5
- import { permissionsSchema } from "./permissions.js";
6
6
  import { z } from "zod";
7
7
 
8
8
  //#region src/auth/platform.ts
@@ -1,10 +1,10 @@
1
1
  import { readRequestCookies, serializeCookie, writeCookies } from "../cookies.js";
2
+ import { permissionsSchema } from "./permissions.js";
2
3
  import { sessionConfig } from "./config.js";
3
4
  import { userSchema } from "./user.js";
4
- import { permissionsSchema } from "./permissions.js";
5
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
5
6
  import { getRequestProtocol } from "@tanstack/react-start/server";
6
7
  import { z } from "zod";
7
- import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
8
8
 
9
9
  //#region src/auth/session.ts
10
10
  const SESSION_TOKEN = "session_token";
@@ -1,5 +1,5 @@
1
- import { isNotFound, isRedirect } from "@tanstack/react-router";
2
1
  import { createMiddleware } from "@tanstack/react-start";
2
+ import { isNotFound, isRedirect } from "@tanstack/react-router";
3
3
 
4
4
  //#region src/boot-error.ts
5
5
  /**
package/dist/client.d.ts CHANGED
@@ -1 +1,3 @@
1
- import "@tanstack/react-start/client-only";
1
+ import { FileItem, useFileUpload } from "./core/use-file-upload.js";
2
+ import "@tanstack/react-start/client-only";
3
+ export { type FileItem, useFileUpload };
package/dist/client.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { useFileUpload } from "./core/use-file-upload.js";
1
2
  import "@tanstack/react-start/client-only";
2
3
 
3
- export { };
4
+ export { useFileUpload };
@@ -0,0 +1,16 @@
1
+ import { DatasourceAction } from "../action.js";
2
+
3
+ //#region src/core/file-upload.d.ts
4
+
5
+ /**
6
+ * The shape of a single uploaded file as it is stored in a record field.
7
+ * Pre-load existing attachments from the record into this shape for edit forms.
8
+ */
9
+ type FileAttachment = {
10
+ url: string;
11
+ filename: string;
12
+ id: string;
13
+ };
14
+ declare function upload(action: DatasourceAction, file: File): Promise<FileAttachment>;
15
+ //#endregion
16
+ export { FileAttachment, upload };
@@ -0,0 +1,34 @@
1
+ import { fetch } from "../fetch.js";
2
+ import { sessionConfig } from "../auth/config.js";
3
+ import { readSessionToken } from "../auth/session.js";
4
+ import { platformApiUrl } from "../auth/platform.js";
5
+ import { randomUUID } from "node:crypto";
6
+
7
+ //#region src/core/file-upload.ts
8
+ async function upload(action, file) {
9
+ const { applicationId } = sessionConfig();
10
+ const baseUrl = await platformApiUrl();
11
+ const uploadUrl = new URL(`${baseUrl}/v1/vibe/applications/${applicationId}/actions/${action.id}/uploads`);
12
+ uploadUrl.searchParams.set("fileName", file.name);
13
+ const formData = new FormData();
14
+ formData.append("file", file);
15
+ const token = readSessionToken();
16
+ const res = await fetch(uploadUrl.toString(), {
17
+ method: "POST",
18
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
19
+ body: formData
20
+ });
21
+ if (!res.ok) throw new Error(`File upload failed: ${res.status}`);
22
+ const rawUrl = await res.json();
23
+ const parsed = new URL(rawUrl);
24
+ const fileId = parsed.searchParams.get("fileId") || randomUUID();
25
+ parsed.searchParams.delete("fileId");
26
+ return {
27
+ url: parsed.toString(),
28
+ filename: file.name || "Untitled file",
29
+ id: fileId
30
+ };
31
+ }
32
+
33
+ //#endregion
34
+ export { upload };
@@ -12,7 +12,7 @@ async function integrationFetch(action, url, { method = "GET", headers = {}, bod
12
12
  headers
13
13
  };
14
14
  if (upperMethod !== "GET" && upperMethod !== "HEAD" && body !== void 0) payload.body = body;
15
- return fetch(`${dsUrl(action.resource.datasourceId)}/proxy`, {
15
+ return fetch(`${dsUrl(action.id)}/proxy`, {
16
16
  method: "POST",
17
17
  headers: {
18
18
  "Content-Type": "application/json",
@@ -0,0 +1,16 @@
1
+ import { assert } from "../assertions.js";
2
+ import { upload } from "./file-upload.js";
3
+ import { createServerFn } from "@tanstack/react-start";
4
+
5
+ //#region src/core/upload-fn.ts
6
+ const uploadServerFn = createServerFn({ method: "POST" }).validator((data) => data).handler(async ({ data }) => {
7
+ const actionJson = data.get("action");
8
+ const file = data.get("file");
9
+ assert(typeof actionJson === "string", "upload: action is required");
10
+ assert(file instanceof File, "upload: file is required");
11
+ const attachment = await upload(JSON.parse(actionJson), file);
12
+ return new Response(JSON.stringify(attachment), { headers: { "Content-Type": "application/json" } });
13
+ });
14
+
15
+ //#endregion
16
+ export { uploadServerFn };
@@ -0,0 +1,28 @@
1
+ import { DatasourceAction } from "../action.js";
2
+ import { FileAttachment } from "./file-upload.js";
3
+
4
+ //#region src/core/use-file-upload.d.ts
5
+ type FileItem = {
6
+ status: 'uploading';
7
+ id: string;
8
+ filename: string;
9
+ progress: number;
10
+ cancel: () => void;
11
+ } | (FileAttachment & {
12
+ status: 'done';
13
+ }) | {
14
+ status: 'error';
15
+ id: string;
16
+ filename: string;
17
+ error: Error;
18
+ };
19
+ declare function useFileUpload(action: DatasourceAction, options?: {
20
+ initialAttachments?: FileAttachment[];
21
+ }): {
22
+ files: FileItem[];
23
+ isUploading: boolean;
24
+ upload: (file: File) => void;
25
+ remove: (id: string) => void;
26
+ };
27
+ //#endregion
28
+ export { FileItem, useFileUpload };
@@ -0,0 +1,105 @@
1
+ import { uploadServerFn } from "./upload-fn.js";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+
4
+ //#region src/core/use-file-upload.ts
5
+ /**
6
+ * The state machine behind useFileUpload, decoupled from React so it can be
7
+ * tested directly with a plain dispatch function.
8
+ */
9
+ function createFileUploadStore(uploadFn, dispatch) {
10
+ let nextId = 0;
11
+ return {
12
+ upload(file) {
13
+ const id = String(++nextId);
14
+ const controller = new AbortController();
15
+ const cancel = () => {
16
+ controller.abort();
17
+ dispatch((prev) => prev.filter((item) => item.id !== id));
18
+ };
19
+ const onProgress = (progress) => {
20
+ dispatch((prev) => prev.map((item) => item.id === id && item.status === "uploading" ? {
21
+ ...item,
22
+ progress
23
+ } : item));
24
+ };
25
+ dispatch((prev) => [...prev, {
26
+ status: "uploading",
27
+ id,
28
+ filename: file.name,
29
+ progress: 0,
30
+ cancel
31
+ }]);
32
+ uploadFn(file, {
33
+ onProgress,
34
+ signal: controller.signal
35
+ }).then((attachment) => {
36
+ dispatch((prev) => prev.map((item) => item.id === id ? {
37
+ ...attachment,
38
+ status: "done"
39
+ } : item));
40
+ }).catch((error) => {
41
+ if (controller.signal.aborted) return;
42
+ dispatch((prev) => prev.map((item) => item.id === id ? {
43
+ status: "error",
44
+ id,
45
+ filename: file.name,
46
+ error: error instanceof Error ? error : /* @__PURE__ */ new Error("Upload failed")
47
+ } : item));
48
+ });
49
+ },
50
+ remove(id) {
51
+ dispatch((prev) => prev.filter((item) => item.id !== id));
52
+ }
53
+ };
54
+ }
55
+ function xhrUpload(action, file, { onProgress, signal }) {
56
+ return new Promise((resolve, reject) => {
57
+ const xhr = new XMLHttpRequest();
58
+ const fd = new FormData();
59
+ fd.append("file", file);
60
+ fd.append("action", JSON.stringify(action));
61
+ xhr.upload.onprogress = (e) => {
62
+ if (e.lengthComputable) onProgress(Math.round(e.loaded / e.total * 100));
63
+ };
64
+ signal.addEventListener("abort", () => {
65
+ xhr.abort();
66
+ reject(new DOMException("Upload cancelled", "AbortError"));
67
+ });
68
+ xhr.onload = () => {
69
+ if (xhr.status < 200 || xhr.status >= 300) {
70
+ reject(/* @__PURE__ */ new Error(`File upload failed: ${xhr.status}`));
71
+ return;
72
+ }
73
+ try {
74
+ resolve(JSON.parse(xhr.responseText));
75
+ } catch {
76
+ reject(/* @__PURE__ */ new Error("File upload: unexpected response format"));
77
+ }
78
+ };
79
+ xhr.onerror = () => reject(/* @__PURE__ */ new Error("File upload failed: network error"));
80
+ xhr.open("POST", uploadServerFn.url);
81
+ xhr.send(fd);
82
+ });
83
+ }
84
+ function useFileUpload(action, options) {
85
+ const [files, dispatch] = useState(() => (options?.initialAttachments ?? []).map((a) => ({
86
+ ...a,
87
+ status: "done"
88
+ })));
89
+ const actionRef = useRef(action);
90
+ useEffect(() => {
91
+ actionRef.current = action;
92
+ });
93
+ const store = useRef(createFileUploadStore((file, opts) => xhrUpload(actionRef.current, file, opts), dispatch));
94
+ const upload = useCallback((file) => store.current.upload(file), []);
95
+ const remove = useCallback((id) => store.current.remove(id), []);
96
+ return {
97
+ files,
98
+ isUploading: files.some((f) => f.status === "uploading"),
99
+ upload,
100
+ remove
101
+ };
102
+ }
103
+
104
+ //#endregion
105
+ export { useFileUpload };
@@ -1,7 +1,7 @@
1
1
  import { fetch } from "../fetch.js";
2
2
  import { sessionConfig } from "../auth/config.js";
3
- import { platformApiUrl } from "../auth/platform.js";
4
3
  import { readSessionToken } from "../auth/session.js";
4
+ import { platformApiUrl } from "../auth/platform.js";
5
5
 
6
6
  //#region src/core/workflow.ts
7
7
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -191,9 +191,9 @@ function mapChartRows(columns, data) {
191
191
  });
192
192
  }
193
193
  async function executeChart(query, state) {
194
- const { datasourceId, select } = resolveTarget(query, "read");
194
+ const { actionId, select } = resolveTarget(query, "read");
195
195
  const { body, columns } = buildChartRequest(state, select);
196
- const res = await fetch(`${dsUrl(datasourceId)}/chart/data`, {
196
+ const res = await fetch(`${dsUrl(actionId)}/chart/data`, {
197
197
  method: "POST",
198
198
  headers: {
199
199
  "Content-Type": "application/json",
@@ -202,7 +202,7 @@ async function executeChart(query, state) {
202
202
  },
203
203
  body: JSON.stringify(body)
204
204
  });
205
- if (!res.ok) throw new Error(`Softr datasource chart failed for "${datasourceId}": ${res.status}`);
205
+ if (!res.ok) throw new Error(`Softr datasource chart failed for action "${actionId}": ${res.status}`);
206
206
  const payload = await res.json();
207
207
  return {
208
208
  data: mapChartRows(columns, payload.data),
@@ -264,9 +264,9 @@ function readMetricValue(responseKey, data) {
264
264
  return row[responseKey];
265
265
  }
266
266
  async function executeMetric(query, handle, filters) {
267
- const { datasourceId, select } = resolveTarget(query, "read");
267
+ const { actionId, select } = resolveTarget(query, "read");
268
268
  const { body, responseKey } = buildMetricRequest(handle, filters, select);
269
- const res = await fetch(`${dsUrl(datasourceId)}/chart/data`, {
269
+ const res = await fetch(`${dsUrl(actionId)}/chart/data`, {
270
270
  method: "POST",
271
271
  headers: {
272
272
  "Content-Type": "application/json",
@@ -275,7 +275,7 @@ async function executeMetric(query, handle, filters) {
275
275
  },
276
276
  body: JSON.stringify(body)
277
277
  });
278
- if (!res.ok) throw new Error(`Softr datasource metric failed for "${datasourceId}": ${res.status}`);
278
+ if (!res.ok) throw new Error(`Softr datasource metric failed for action "${actionId}": ${res.status}`);
279
279
  const payload = await res.json();
280
280
  return {
281
281
  value: readMetricValue(responseKey, payload.data),
@@ -1,19 +1,19 @@
1
1
  import { fetch } from "../fetch.js";
2
2
  import { assert, panic } from "../assertions.js";
3
- import { isDatasourceAction } from "../action.js";
4
3
  import { readUserTimezone } from "../cookies.js";
5
4
  import { readSessionToken } from "../auth/session.js";
5
+ import { isDatasourceAction } from "../action.js";
6
6
  import { buildFilterCriteria, buildOrderBy, dealiasFilterCriteria, dealiasOrderBy } from "./filter.js";
7
7
  import { andAll, makeFieldSet, makeSortSet } from "./fields.js";
8
8
 
9
9
  //#region src/datasource/record.ts
10
10
  const MAX_RECORD_COUNT = 100;
11
- const dsUrl = (datasourceId) => {
11
+ const dsUrl = (actionId) => {
12
12
  const datasourceApiUrl = process.env.SOFTR_DATASOURCE_API_URL;
13
13
  if (!datasourceApiUrl) panic("SOFTR_DATASOURCE_API_URL is not set.");
14
14
  const applicationId = process.env.SOFTR_APPLICATION_ID;
15
15
  if (!applicationId) panic("SOFTR_APPLICATION_ID is not set.");
16
- return `${withoutTrailingSlash(datasourceApiUrl)}/v1/datasource/vibe/applications/${applicationId}/datasources/${datasourceId}`;
16
+ return `${withoutTrailingSlash(datasourceApiUrl)}/v1/datasource/vibe/applications/${applicationId}/actions/${actionId}`;
17
17
  };
18
18
  /**
19
19
  * Queries a list of records from the datasource.
@@ -65,12 +65,12 @@ function listRecords(query) {
65
65
  return builder;
66
66
  }
67
67
  async function executeList(query, args) {
68
- const { datasourceId, select } = resolveTarget(query, "read");
68
+ const { actionId, select } = resolveTarget(query, "read");
69
69
  const count = Math.min(args.limit ?? 20, MAX_RECORD_COUNT);
70
70
  const offset = args.offset ?? null;
71
71
  const filterCriteria = args.filters.length ? dealiasFilterCriteria(buildFilterCriteria(andAll(args.filters)), select) : {};
72
72
  const sortingOption = args.orderBy.length ? dealiasOrderBy(buildOrderBy(args.orderBy), select) : void 0;
73
- const res = await fetch(`${dsUrl(datasourceId)}/records`, {
73
+ const res = await fetch(`${dsUrl(actionId)}/records`, {
74
74
  method: "POST",
75
75
  headers: {
76
76
  "Content-Type": "application/json",
@@ -87,7 +87,7 @@ async function executeList(query, args) {
87
87
  }
88
88
  })
89
89
  });
90
- if (!res.ok) throw new Error(`Softr datasource list failed for "${datasourceId}": ${res.status}`);
90
+ if (!res.ok) throw new Error(`Softr datasource list failed for "${actionId}": ${res.status}`);
91
91
  const payload = await res.json();
92
92
  const nextOffset = payload.offset < payload.total ? payload.offset : null;
93
93
  return {
@@ -139,9 +139,9 @@ function getRecord(query, options) {
139
139
  }
140
140
  async function executeGet(query, id, filters) {
141
141
  assert(id, "a record id must not be empty");
142
- const { datasourceId, select } = resolveTarget(query, "read");
142
+ const { actionId, select } = resolveTarget(query, "read");
143
143
  const filterCriteria = filters.length ? dealiasFilterCriteria(buildFilterCriteria(andAll(filters)), select) : {};
144
- const res = await fetch(`${dsUrl(datasourceId)}/records/${id}`, {
144
+ const res = await fetch(`${dsUrl(actionId)}/records/${id}`, {
145
145
  method: "POST",
146
146
  headers: {
147
147
  "Content-Type": "application/json",
@@ -153,7 +153,7 @@ async function executeGet(query, id, filters) {
153
153
  filterCriteria
154
154
  })
155
155
  });
156
- if (!res.ok) throw new Error(`Softr datasource get failed for "${datasourceId}": ${res.status}`);
156
+ if (!res.ok) throw new Error(`Softr datasource get failed for action "${actionId}": ${res.status}`);
157
157
  const contentLength = res.headers.get("Content-Length");
158
158
  if (contentLength && parseInt(contentLength, 10) === 0) return null;
159
159
  const record = await res.json();
@@ -171,10 +171,10 @@ async function executeGet(query, id, filters) {
171
171
  * })
172
172
  */
173
173
  async function createRecord(action, options) {
174
- const { datasourceId, select } = resolveTarget(action, "create");
174
+ const { actionId, select } = resolveTarget(action, "create");
175
175
  const data = options.data;
176
176
  const rawFields = dealiasData(data, select);
177
- const res = await fetch(`${dsUrl(datasourceId)}/records/new`, {
177
+ const res = await fetch(`${dsUrl(actionId)}/records/new`, {
178
178
  method: "POST",
179
179
  headers: {
180
180
  "Content-Type": "application/json",
@@ -182,7 +182,7 @@ async function createRecord(action, options) {
182
182
  },
183
183
  body: JSON.stringify({ fields: rawFields })
184
184
  });
185
- if (!res.ok) throw new Error(`Softr datasource create failed for "${datasourceId}": ${res.status}`);
185
+ if (!res.ok) throw new Error(`Softr datasource create failed for action "${actionId}": ${res.status}`);
186
186
  const record = await res.json();
187
187
  return {
188
188
  id: record.id,
@@ -200,10 +200,10 @@ async function createRecord(action, options) {
200
200
  */
201
201
  async function updateRecord(action, options) {
202
202
  if (!options.id) throw new Error(`Softr datasource update: id must not be empty`);
203
- const { datasourceId, select } = resolveTarget(action, "update");
203
+ const { actionId, select } = resolveTarget(action, "update");
204
204
  const data = options.data;
205
205
  const rawFields = dealiasData(data, select);
206
- const res = await fetch(`${dsUrl(datasourceId)}/records/${options.id}`, {
206
+ const res = await fetch(`${dsUrl(actionId)}/records/${options.id}`, {
207
207
  method: "PATCH",
208
208
  headers: {
209
209
  "Content-Type": "application/json",
@@ -211,7 +211,7 @@ async function updateRecord(action, options) {
211
211
  },
212
212
  body: JSON.stringify({ fields: rawFields })
213
213
  });
214
- if (!res.ok) throw new Error(`Softr datasource update failed for "${datasourceId}": ${res.status}`);
214
+ if (!res.ok) throw new Error(`Softr datasource update failed for action "${actionId}": ${res.status}`);
215
215
  const record = await res.json();
216
216
  return {
217
217
  id: record.id,
@@ -225,12 +225,12 @@ async function updateRecord(action, options) {
225
225
  * await ds.delete(removePost, { id: "record-id" })
226
226
  */
227
227
  async function deleteRecord(action, options) {
228
- const { datasourceId } = resolveTarget(action, "delete");
229
- const res = await fetch(`${dsUrl(datasourceId)}/records/${options.id}`, {
228
+ const { actionId } = resolveTarget(action, "delete");
229
+ const res = await fetch(`${dsUrl(actionId)}/records/${options.id}`, {
230
230
  method: "DELETE",
231
231
  headers: { Authorization: `Bearer ${readSessionToken()}` }
232
232
  });
233
- if (!res.ok) throw new Error(`Softr datasource delete failed for "${datasourceId}": ${res.status}`);
233
+ if (!res.ok) throw new Error(`Softr datasource delete failed for action "${actionId}": ${res.status}`);
234
234
  }
235
235
  function resolveTarget(subject, operation) {
236
236
  const declaredBy = operation === "read" ? "defineQuery" : "defineMutation";
@@ -238,7 +238,7 @@ function resolveTarget(subject, operation) {
238
238
  const action = subject;
239
239
  assert(action.resource.operation === operation, () => `action "${action.id}" is declared as ${action.resource.operation}, not ${operation}`);
240
240
  return {
241
- datasourceId: action.resource.datasourceId,
241
+ actionId: action.id,
242
242
  select: action.resource.fields ?? {}
243
243
  };
244
244
  }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { SoftrUserGroupId, SoftrUserGroups, User } from "./auth/user.js";
2
2
  import { ActionPermissions, SoftrDatasources, defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
3
+ import { FileAttachment } from "./core/file-upload.js";
3
4
  import { SoftrContext } from "./auth/context.js";
4
5
  import { defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError } from "./routes/visibility.js";
5
6
  import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser, usePermissions } from "./auth/isomorphic.js";
6
7
  import { useUserTimezone } from "./timezone.js";
7
8
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
8
- export { type ActionPermissions, DefaultErrorOverlay, type SoftrContext, type SoftrDatasources, type SoftrUserGroupId, type SoftrUserGroups, type User, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, usePermissions, useUserTimezone };
9
+ export { type ActionPermissions, DefaultErrorOverlay, type FileAttachment, type SoftrContext, type SoftrDatasources, type SoftrUserGroupId, type SoftrUserGroups, type User, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, usePermissions, useUserTimezone };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.0.32";
2
+ var version = "0.0.35-alpha.0";
3
3
 
4
4
  //#endregion
5
5
  export { version };
package/dist/server.d.ts CHANGED
@@ -1,6 +1,7 @@
1
+ import { upload } from "./core/file-upload.js";
1
2
  import { auth } from "./auth/server.js";
2
3
  import { ds } from "./datasource/datasource.js";
3
4
  import { workflow } from "./core/workflow.js";
4
5
  import { integration } from "./core/integration.js";
5
6
  import "@tanstack/react-start/server-only";
6
- export { auth, ds, integration, workflow };
7
+ export { auth, ds, integration, upload, workflow };
package/dist/server.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { upload } from "./core/file-upload.js";
1
2
  import { auth } from "./auth/server.js";
2
3
  import { ds } from "./datasource/datasource.js";
3
4
  import { workflow } from "./core/workflow.js";
4
5
  import { integration } from "./core/integration.js";
5
6
  import "@tanstack/react-start/server-only";
6
7
 
7
- export { auth, ds, integration, workflow };
8
+ export { auth, ds, integration, upload, workflow };
package/dist/vite.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { PluginOption } from "vite";
2
2
 
3
3
  //#region src/vite.d.ts
4
- /** Everything Softr adds to an app's Vite config: the studio edit engine, when the pod supplies one. */
4
+ /** Everything Softr adds to an app's Vite config: the studio edit engine, when there is a pod. */
5
5
  declare function softr(): PluginOption[];
6
6
  //#endregion
7
7
  export { softr };
package/dist/vite.js CHANGED
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
3
 
4
4
  //#region src/vite.ts
5
- /** Everything Softr adds to an app's Vite config: the studio edit engine, when the pod supplies one. */
5
+ /** Everything Softr adds to an app's Vite config: the studio edit engine, when there is a pod. */
6
6
  function softr() {
7
7
  return [editEngine()];
8
8
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.32",
3
+ "version": "0.0.35-alpha.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
@@ -11,7 +11,7 @@ description: >-
11
11
  metadata:
12
12
  type: core
13
13
  library: softr-sdk
14
- library_version: 0.0.32
14
+ library_version: 0.0.35-alpha.0
15
15
  ---
16
16
 
17
17
  # Softr SDK core
@@ -16,7 +16,7 @@ description: >-
16
16
  metadata:
17
17
  type: sub-skill
18
18
  library: softr-sdk
19
- library_version: 0.0.32
19
+ library_version: 0.0.35-alpha.0
20
20
  ---
21
21
 
22
22
  **Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineVisibility`, the hooks and every type come from `@softr/sdk`. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
@@ -12,7 +12,7 @@ description: >-
12
12
  metadata:
13
13
  type: sub-skill
14
14
  library: softr-sdk
15
- library_version: 0.0.32
15
+ library_version: 0.0.35-alpha.0
16
16
  ---
17
17
 
18
18
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.32
13
+ library_version: 0.0.35-alpha.0
14
14
  ---
15
15
 
16
16
  **Boundary.** `useCurrentUser`, `usePermissions` and every type come from `@softr/sdk` and work anywhere; `ds` and `auth` live on `@softr/sdk/server` and run only inside a `createServerFn` handler. The app is already wired — `src/start.ts` registers `softrAuthMiddleware`, `src/routes/__root.tsx` publishes the visitor as route context — never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
@@ -0,0 +1,153 @@
1
+ ---
2
+ name: file-uploads
3
+ description: >-
4
+ Upload files from a form or edit view: useFileUpload(action) from @softr/sdk/client does
5
+ everything — XHR upload with real progress, per-upload cancel, and isUploading to block
6
+ form submission. No route registration needed; the SDK ships its own server function.
7
+ For server-side uploads or plain forms, use upload(actionId, file) from @softr/sdk/server.
8
+ For edit forms pass initialAttachments to pre-load existing attachments.
9
+ metadata:
10
+ type: sub-skill
11
+ library: softr-sdk
12
+ library_version: 0.0.35-alpha.0
13
+ ---
14
+
15
+ **Boundary.** `upload` lives on `@softr/sdk/server` and runs inside server functions. `useFileUpload` and `FileItem` live on `@softr/sdk/client` and run only in components. `FileAttachment` comes from `@softr/sdk` and can be imported anywhere.
16
+
17
+ # File uploads
18
+
19
+ `useFileUpload` handles the full upload cycle — XHR with real progress, per-upload cancel, and state management. It uses a server function the SDK registers itself, so no route file is needed in the app.
20
+
21
+ ## Setup
22
+
23
+ ```ts
24
+ import { defineMutation, type FileAttachment } from '@softr/sdk'
25
+ import { ds } from '@softr/sdk/server'
26
+ import { useFileUpload, type FileItem } from '@softr/sdk/client'
27
+ ```
28
+
29
+ ## Core pattern
30
+
31
+ ```ts
32
+ const createApplication = defineMutation({
33
+ id: 'createApplication',
34
+ datasourceId: '8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77',
35
+ operation: 'create',
36
+ fields: { name: 'V1StGXR8', resume: 'Z7pQm4Kd' },
37
+ permissions: { userGroup: 'LOGGED_IN_USERS' },
38
+ })
39
+
40
+ const submitApplication = createServerFn({ method: 'POST' })
41
+ .validator((data: { name: string; resume: FileAttachment[] }) => data)
42
+ .handler(async ({ data }) => {
43
+ await ds.create(createApplication, { data })
44
+ })
45
+
46
+ function ApplicationForm() {
47
+ const { files, isUploading, upload, remove } = useFileUpload(createApplication)
48
+
49
+ return (
50
+ <form onSubmit={async (e) => {
51
+ e.preventDefault()
52
+ if (isUploading) return
53
+ const resume = files.filter(f => f.status === 'done') as FileAttachment[]
54
+ await submitApplication({ data: { name: e.currentTarget.name.value, resume } })
55
+ }}>
56
+ <input name="name" />
57
+ <input type="file" onChange={(e) => { const f = e.target.files?.[0]; if (f) upload(f) }} />
58
+ {files.map((f) => (
59
+ <div key={f.id}>
60
+ {f.filename}
61
+ {f.status === 'uploading' && <> — {f.progress}% <button type="button" onClick={f.cancel}>Cancel</button></>}
62
+ {f.status === 'done' && <button type="button" onClick={() => remove(f.id)}>Remove</button>}
63
+ {f.status === 'error' && <span> — Failed: {f.error.message}</span>}
64
+ </div>
65
+ ))}
66
+ <button type="submit" disabled={isUploading}>Submit</button>
67
+ </form>
68
+ )
69
+ }
70
+ ```
71
+
72
+ `useFileUpload` takes the same `DatasourceAction` you pass to `ds.create` / `ds.update` — no separate upload action to declare.
73
+
74
+ ## What `FileAttachment` looks like
75
+
76
+ ```ts
77
+ type FileAttachment = {
78
+ url: string // clean URL, no internal query params
79
+ filename: string // original filename
80
+ id: string // file id extracted from the upload response
81
+ }
82
+ ```
83
+
84
+ Pass `attachments` as the field value in `ds.create` / `ds.update`.
85
+
86
+ ## FileItem shape
87
+
88
+ `files` is a single ordered array. Each entry is a discriminated union on `status`:
89
+
90
+ ```ts
91
+ type FileItem =
92
+ | { status: 'uploading'; id: string; filename: string; progress: number; cancel: () => void }
93
+ | { status: 'done'; id: string; filename: string; url: string } // FileAttachment + status
94
+ | { status: 'error'; id: string; filename: string; error: Error }
95
+ ```
96
+
97
+ Items appear in insertion order and transition in place — a completed upload stays at its original position rather than moving to a separate list.
98
+
99
+ For form submission, filter to `done` items: `files.filter(f => f.status === 'done') as FileAttachment[]`.
100
+
101
+ ## Blocking form submission
102
+
103
+ `isUploading` is `true` while any item has `status: 'uploading'`. Disable the submit button and guard the submit handler.
104
+
105
+ ## Edit forms: pre-loading existing attachments
106
+
107
+ ```ts
108
+ const { files, isUploading, upload, remove } = useFileUpload(
109
+ updateApplication,
110
+ { initialAttachments: record.fields.resume ?? [] },
111
+ )
112
+ ```
113
+
114
+ Pre-loaded attachments appear as `status: 'done'` items at the start of `files`. The existing attachments already have the `FileAttachment` shape from the datasource record.
115
+
116
+ ## Server-side uploads and plain forms
117
+
118
+ For server-side use (no progress tracking needed) or plain HTML forms, use `upload` directly:
119
+
120
+ ```ts
121
+ import { upload, ds } from '@softr/sdk/server'
122
+
123
+ const submitResume = createServerFn({ method: 'POST' })
124
+ .validator((fields: FormData) => fields)
125
+ .handler(async ({ data }) => {
126
+ const uploaded = await upload(createApplication, data.get('cv') as File)
127
+ await ds.create(createApplication, {
128
+ data: { fullName: data.get('fullName') as string, cv: [uploaded] },
129
+ })
130
+ return new Response(null, { status: 303, headers: { Location: '/thank-you' } })
131
+ })
132
+
133
+ function ApplyForm() {
134
+ return (
135
+ <form action={submitResume.url} method="POST">
136
+ <input type="text" name="fullName" />
137
+ <input type="file" name="cv" />
138
+ <button type="submit">Apply for this role</button>
139
+ </form>
140
+ )
141
+ }
142
+ ```
143
+
144
+ ## Failure modes
145
+
146
+ **Submitting while `isUploading` is true.** Only `done` items have a `url` and should be submitted. In-progress uploads finish after the record is saved and are never included. Always filter to `status === 'done'` before passing to `ds.create` / `ds.update`.
147
+
148
+ **Not passing `initialAttachments` on an edit form.** The hook starts with an empty list. Without pre-loading, the first save erases existing attachments.
149
+
150
+ ## References
151
+
152
+ - [record-mutations/SKILL.md](../record-mutations/SKILL.md): creating and updating records with the collected attachments
153
+ - [current-user/SKILL.md](../current-user/SKILL.md): gating uploads to the signed-in user
@@ -2,14 +2,14 @@
2
2
  name: integrations
3
3
  description: >-
4
4
  Call a third-party service connected to the app as an integration:
5
- defineIntegration from @softr/sdk declares the action with its datasourceId
5
+ defineIntegration from @softr/sdk declares the action with its integrationId
6
6
  and permissions; integration.createFetcher from @softr/sdk/server binds it and
7
7
  returns a fetcher function you call with url, optional method, headers, and
8
8
  body. Check res.ok before res.json().
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.32
12
+ library_version: 0.0.35-alpha.0
13
13
  ---
14
14
 
15
15
  **Boundary.** `integration` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineIntegration` and every type come from `@softr/sdk` and can be imported anywhere. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
@@ -34,14 +34,14 @@ When the user attaches integrations to the chat, they appear in the `## Attached
34
34
  - "Gmail" — database `<databaseId>`, integration `<integrationId>`, type `GMAIL`
35
35
  ```
36
36
 
37
- Use `databaseId` as `datasourceId` in `defineIntegration` — that is the `database` coordinate, not the `integration` one. The `integration` value is the OAuth connection identifier; do not pass it anywhere. Use `integrationType` (e.g. `GMAIL`) to look up the base URL from the reference table below — then construct the full URL path, including any query parameters, directly in the `url` string.
37
+ Pass the `integration` value as `integrationId` in `defineIntegration`. The `database` value is the table coordinate; integrations do not use it, so do not pass it anywhere. Use `integrationType` (e.g. `GMAIL`) to look up the base URL from the reference table below — then construct the full URL path, including any query parameters, directly in the `url` string.
38
38
 
39
39
  ## Declare the integration action
40
40
 
41
41
  ```ts
42
42
  const googleCalendar = defineIntegration({
43
43
  id: 'googleCalendar',
44
- datasourceId: '25d60213-ef4f-4fa0-b094-a46931b2d120',
44
+ integrationId: '25d60213-ef4f-4fa0-b094-a46931b2d120',
45
45
  permissions: { userGroup: 'LOGGED_IN_USERS' },
46
46
  })
47
47
  ```
@@ -71,7 +71,7 @@ Include query parameters directly in the `url` string — there is no separate `
71
71
  ```ts
72
72
  const slackDs = defineIntegration({
73
73
  id: 'slack',
74
- datasourceId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
74
+ integrationId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
75
75
  permissions: { userGroup: 'LOGGED_IN_USERS' },
76
76
  })
77
77
 
@@ -11,7 +11,7 @@ description: >-
11
11
  metadata:
12
12
  type: sub-skill
13
13
  library: softr-sdk
14
- library_version: 0.0.32
14
+ library_version: 0.0.35-alpha.0
15
15
  ---
16
16
 
17
17
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineMutation` and every type come from `@softr/sdk` and can be imported anywhere. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
@@ -12,7 +12,7 @@ description: >-
12
12
  metadata:
13
13
  type: sub-skill
14
14
  library: softr-sdk
15
- library_version: 0.0.32
15
+ library_version: 0.0.35-alpha.0
16
16
  ---
17
17
 
18
18
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
@@ -12,7 +12,7 @@ description: >-
12
12
  metadata:
13
13
  type: sub-skill
14
14
  library: softr-sdk
15
- library_version: 0.0.32
15
+ library_version: 0.0.35-alpha.0
16
16
  ---
17
17
 
18
18
  **Boundary.** `defineVisibility`, the error predicates and every type come from `@softr/sdk` and work anywhere; `ds` and `auth` live on `@softr/sdk/server` and run only inside a `createServerFn` handler. The app is already wired — `src/start.ts` registers `softrAuthMiddleware`, `src/routes/__root.tsx` publishes the visitor as route context — never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.32
12
+ library_version: 0.0.35-alpha.0
13
13
  ---
14
14
 
15
15
  **Boundary.** `workflow` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineWorkflow` and every type come from `@softr/sdk` and can be imported anywhere. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.