@softr/sdk 0.0.30-alpha.0 → 0.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/action.d.ts CHANGED
@@ -2,7 +2,7 @@ import { SoftrUserGroupId } from "./auth/user.js";
2
2
 
3
3
  //#region src/action.d.ts
4
4
  type ActionOperation = "read" | "create" | "update" | "delete";
5
- type ActionPermission = {
5
+ type ActionPermissions = {
6
6
  userGroup: "ALL_USERS";
7
7
  } | {
8
8
  userGroup: "NON_LOGGED_IN_USERS";
@@ -10,6 +10,11 @@ type ActionPermission = {
10
10
  userGroup: "LOGGED_IN_USERS";
11
11
  userGroupIds?: Array<SoftrUserGroupId>;
12
12
  };
13
+ type Action<TResource> = {
14
+ id: string;
15
+ resource: TResource;
16
+ permissions: ActionPermissions;
17
+ };
13
18
  /**
14
19
  * Describes the datasources available in the app and their read, create and update types.
15
20
  *
@@ -42,7 +47,7 @@ type FieldIdOf<TDatasource$1, TOperation$1> = keyof SoftrDatasources extends nev
42
47
  type ReadFieldIdOf<TDatasource$1> = keyof SoftrDatasources extends never ? string : keyof ReadOf<TDatasource$1> & string;
43
48
  type ActionShape = {
44
49
  id: string;
45
- datasource: SoftrDatasourceId;
50
+ datasourceId: SoftrDatasourceId;
46
51
  operation: ActionOperation;
47
52
  fields?: Record<string, string>;
48
53
  };
@@ -51,40 +56,48 @@ type ReadShape = ActionShape & {
51
56
  fields: Record<string, string>;
52
57
  };
53
58
  type ActionData<TShape extends ActionShape> = keyof SoftrDatasources extends never ? Record<string, unknown> : TShape extends {
54
- datasource: infer TDatasource;
59
+ datasourceId: infer TDatasource;
55
60
  operation: infer TOperation;
56
61
  fields: infer TFields;
57
62
  } ? { [Alias in keyof TFields]?: WriteOf<TDatasource, TOperation>[TFields[Alias] & keyof WriteOf<TDatasource, TOperation>] } : Record<string, unknown>;
58
63
  type ActionRecord<TShape extends ActionShape> = keyof SoftrDatasources extends never ? Record<string, unknown> : TShape extends {
59
- datasource: infer TDatasource;
64
+ datasourceId: infer TDatasource;
60
65
  fields: infer TFields;
61
66
  } ? { [Alias in keyof TFields]: ReadOf<TDatasource>[TFields[Alias] & keyof ReadOf<TDatasource>] } : Record<string, unknown>;
62
67
  declare const brand: unique symbol;
63
- type Action<TShape extends ActionShape = ActionShape> = {
64
- type: "action";
68
+ type DatasourceResource = {
69
+ type: "DATASOURCE";
70
+ datasourceId: SoftrDatasourceId;
71
+ operation: ActionOperation;
72
+ fields?: Record<string, string>;
73
+ };
74
+ /**
75
+ * A datasource-backed action (query or mutation). The `TShape` brand carries field and
76
+ * operation type info for compile-time checking of `ds.*` calls.
77
+ */
78
+ type DatasourceAction<TShape extends ActionShape = ActionShape> = Action<DatasourceResource> & {
65
79
  id: TShape["id"];
66
- } & {
67
80
  readonly [brand]: TShape;
68
81
  };
69
82
  type WriteDeclaration<TId extends string, TDatasource$1, TOperation$1, TFields$1> = {
70
83
  id: TId;
71
- datasource: TDatasource$1;
84
+ datasourceId: TDatasource$1;
72
85
  operation: TOperation$1;
73
86
  fields: TFields$1;
74
- permission: ActionPermission;
87
+ permissions: ActionPermissions;
75
88
  };
76
89
  type ReadDeclaration<TId extends string, TDatasource$1, TFields$1> = {
77
90
  id: TId;
78
- datasource: TDatasource$1;
91
+ datasourceId: TDatasource$1;
79
92
  fields: TFields$1;
80
- permission: ActionPermission;
93
+ permissions: ActionPermissions;
81
94
  };
82
95
  type DeleteDeclaration<TId extends string, TDatasource$1> = {
83
96
  id: TId;
84
- datasource: TDatasource$1;
97
+ datasourceId: TDatasource$1;
85
98
  operation: "delete";
86
99
  fields?: never;
87
- permission: ActionPermission;
100
+ permissions: ActionPermissions;
88
101
  };
89
102
  /**
90
103
  * Declares a read: which datasource, which fields participate, and who may run it.
@@ -94,9 +107,9 @@ type DeleteDeclaration<TId extends string, TDatasource$1> = {
94
107
  * @example
95
108
  * const publishedPosts = defineQuery({
96
109
  * id: "publishedPosts",
97
- * datasource: "8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77",
110
+ * datasourceId: "8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77",
98
111
  * fields: { title: "V1StGXR8", author: "Z7pQm4Kd", publishedAt: "Xp2dLh6v" },
99
- * permission: { userGroup: "ALL_USERS" },
112
+ * permissions: { userGroup: "ALL_USERS" },
100
113
  * })
101
114
  *
102
115
  * const { records } = await ds
@@ -104,9 +117,9 @@ type DeleteDeclaration<TId extends string, TDatasource$1> = {
104
117
  * .where((q) => q.publishedAt.lte(today))
105
118
  * .orderBy((q) => q.publishedAt.desc)
106
119
  */
107
- declare function defineQuery<const TId extends string, const TDatasource$1 extends SoftrDatasourceId, const TFields$1 extends Record<string, ReadFieldIdOf<TDatasource$1>>>(definition: ReadDeclaration<TId, TDatasource$1, TFields$1>): Action<{
120
+ declare function defineQuery<const TId extends string, const TDatasource$1 extends SoftrDatasourceId, const TFields$1 extends Record<string, ReadFieldIdOf<TDatasource$1>>>(definition: ReadDeclaration<TId, TDatasource$1, TFields$1>): DatasourceAction<{
108
121
  id: TId;
109
- datasource: TDatasource$1;
122
+ datasourceId: TDatasource$1;
110
123
  operation: "read";
111
124
  fields: TFields$1;
112
125
  }>;
@@ -119,10 +132,10 @@ declare function defineQuery<const TId extends string, const TDatasource$1 exten
119
132
  * @example
120
133
  * const closeOrder = defineMutation({
121
134
  * id: "closeOrder",
122
- * datasource: "8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77",
135
+ * datasourceId: "8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77",
123
136
  * operation: "update",
124
137
  * fields: { status: "V1StGXR8", closedAt: "Z7pQm4Kd" },
125
- * permission: { userGroup: "LOGGED_IN_USERS", userGroupIds: ["grp_staff"] },
138
+ * permissions: { userGroup: "LOGGED_IN_USERS", userGroupIds: ["grp_staff"] },
126
139
  * })
127
140
  *
128
141
  * await ds.update(closeOrder, { id, data: { status: "closed" } })
@@ -130,23 +143,88 @@ declare function defineQuery<const TId extends string, const TDatasource$1 exten
130
143
  * @example
131
144
  * const removeOrder = defineMutation({
132
145
  * id: "removeOrder",
133
- * datasource: "8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77",
146
+ * datasourceId: "8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77",
134
147
  * operation: "delete",
135
- * permission: { userGroup: "LOGGED_IN_USERS", userGroupIds: ["grp_staff"] },
148
+ * permissions: { userGroup: "LOGGED_IN_USERS", userGroupIds: ["grp_staff"] },
136
149
  * })
137
150
  *
138
151
  * await ds.delete(removeOrder, { id })
139
152
  */
140
- declare function defineMutation<const TId extends string, const TDatasource$1 extends SoftrDatasourceId, const TOperation$1 extends "create" | "update", const TFields$1 extends Record<string, FieldIdOf<TDatasource$1, TOperation$1>>>(definition: WriteDeclaration<TId, TDatasource$1, TOperation$1, TFields$1>): Action<{
153
+ declare function defineMutation<const TId extends string, const TDatasource$1 extends SoftrDatasourceId, const TOperation$1 extends "create" | "update", const TFields$1 extends Record<string, FieldIdOf<TDatasource$1, TOperation$1>>>(definition: WriteDeclaration<TId, TDatasource$1, TOperation$1, TFields$1>): DatasourceAction<{
141
154
  id: TId;
142
- datasource: TDatasource$1;
155
+ datasourceId: TDatasource$1;
143
156
  operation: TOperation$1;
144
157
  fields: TFields$1;
145
158
  }>;
146
- declare function defineMutation<const TId extends string, const TDatasource$1 extends SoftrDatasourceId>(definition: DeleteDeclaration<TId, TDatasource$1>): Action<{
159
+ declare function defineMutation<const TId extends string, const TDatasource$1 extends SoftrDatasourceId>(definition: DeleteDeclaration<TId, TDatasource$1>): DatasourceAction<{
147
160
  id: TId;
148
- datasource: TDatasource$1;
161
+ datasourceId: TDatasource$1;
149
162
  operation: "delete";
150
163
  }>;
164
+ type WorkflowTemplateValue = "{LOGGED_IN_USER:EMAIL}" | "{LOGGED_IN_USER:NAME}" | "{LOGGED_IN_USER:RECORD_ID}" | `{LOGGED_IN_USER:${string}}` | "{CURRENT_DATE_TIME}" | "{CURRENT_DATE}";
165
+ type WorkflowBodyValue = WorkflowTemplateValue | (string & {}) | number | boolean | null;
166
+ type WorkflowResource = {
167
+ type: "WORKFLOW";
168
+ workflowId: string;
169
+ triggerId: string;
170
+ body?: Record<string, WorkflowBodyValue>;
171
+ };
172
+ type WorkflowAction<TId extends string = string> = Action<WorkflowResource> & {
173
+ id: TId;
174
+ };
175
+ type WorkflowDefinition<TId extends string> = {
176
+ id: TId;
177
+ workflowId: string;
178
+ triggerId: string;
179
+ permissions: ActionPermissions;
180
+ body?: Record<string, WorkflowBodyValue>;
181
+ };
182
+ /**
183
+ * Declares a workflow trigger: which workflow, which trigger, who may run it, and any
184
+ * hardcoded data resolved server-side (e.g. logged-in user fields, current date).
185
+ *
186
+ * Pass the returned object to `workflow.trigger()` at runtime — optionally with additional
187
+ * payload. Values in `body` are resolved by the server and cannot be overridden at runtime.
188
+ *
189
+ * @example
190
+ * const notifyTeam = defineWorkflow({
191
+ * id: "notifyTeam",
192
+ * workflowId: "wf_abc123",
193
+ * triggerId: "tr_xyz789",
194
+ * permissions: { userGroup: "LOGGED_IN_USERS" },
195
+ * body: { requestedBy: "{LOGGED_IN_USER:EMAIL}", at: "{CURRENT_DATE_TIME}" },
196
+ * });
197
+ *
198
+ * await workflow.trigger(notifyTeam, { subject: "Hello" });
199
+ */
200
+ declare function defineWorkflow<const TId extends string>(definition: WorkflowDefinition<TId>): WorkflowAction<TId>;
201
+ type IntegrationResource = {
202
+ type: "INTEGRATION";
203
+ datasourceId: string;
204
+ };
205
+ type IntegrationAction<TId extends string = string> = Action<IntegrationResource> & {
206
+ id: TId;
207
+ };
208
+ type IntegrationDefinition<TId extends string> = {
209
+ id: TId;
210
+ datasourceId: string;
211
+ permissions: ActionPermissions;
212
+ };
213
+ /**
214
+ * Declares an integration: which connected data source to call through, and who may call it.
215
+ *
216
+ * Pass the returned object to `integration.createFetcher()` to get a bound fetcher.
217
+ *
218
+ * @example
219
+ * const googleCalendar = defineIntegration({
220
+ * id: "googleCalendar",
221
+ * datasourceId: "25d60213-ef4f-4fa0-b094-a46931b2d120",
222
+ * permissions: { userGroup: "LOGGED_IN_USERS" },
223
+ * })
224
+ *
225
+ * const calendar = integration.createFetcher(googleCalendar)
226
+ * const res = await calendar('https://www.googleapis.com/calendar/v3/calendars/primary/events')
227
+ */
228
+ declare function defineIntegration<const TId extends string>(definition: IntegrationDefinition<TId>): IntegrationAction<TId>;
151
229
  //#endregion
152
- export { Action, ActionData, ActionRecord, ActionShape, FieldTypeOf, ReadOf, ReadShape, SoftrDatasources, defineMutation, defineQuery };
230
+ export { ActionData, ActionPermissions, ActionRecord, ActionShape, DatasourceAction, FieldTypeOf, IntegrationAction, ReadOf, ReadShape, SoftrDatasources, WorkflowAction, defineIntegration, defineMutation, defineQuery, defineWorkflow };
package/dist/action.js CHANGED
@@ -1,22 +1,59 @@
1
1
  //#region src/action.ts
2
2
  function defineQuery(definition) {
3
3
  return {
4
- type: "action",
5
- ...definition,
6
- operation: "read"
4
+ id: definition.id,
5
+ resource: {
6
+ type: "DATASOURCE",
7
+ datasourceId: definition.datasourceId,
8
+ operation: "read",
9
+ fields: definition.fields
10
+ },
11
+ permissions: definition.permissions
7
12
  };
8
13
  }
9
14
  function defineMutation(definition) {
15
+ const { id, datasourceId, operation, fields, permissions } = definition;
10
16
  return {
11
- type: "action",
12
- ...definition
17
+ id,
18
+ resource: {
19
+ type: "DATASOURCE",
20
+ datasourceId,
21
+ operation,
22
+ fields
23
+ },
24
+ permissions
13
25
  };
14
26
  }
15
- function isAction(value) {
27
+ function defineWorkflow(definition) {
28
+ const { id, workflowId, triggerId, body, permissions } = definition;
29
+ return {
30
+ id,
31
+ resource: {
32
+ type: "WORKFLOW",
33
+ workflowId,
34
+ triggerId,
35
+ body
36
+ },
37
+ permissions
38
+ };
39
+ }
40
+ function defineIntegration(definition) {
41
+ const { id, datasourceId, permissions } = definition;
42
+ return {
43
+ id,
44
+ resource: {
45
+ type: "INTEGRATION",
46
+ datasourceId
47
+ },
48
+ permissions
49
+ };
50
+ }
51
+ function isDatasourceAction(value) {
16
52
  if (typeof value !== "object" || value === null) return false;
17
53
  const candidate = value;
18
- return candidate.type === "action" && typeof candidate.id === "string" && typeof candidate.datasource === "string";
54
+ const resource = candidate.resource;
55
+ return typeof candidate.id === "string" && typeof resource === "object" && resource !== null && resource.type === "DATASOURCE";
19
56
  }
20
57
 
21
58
  //#endregion
22
- export { defineMutation, defineQuery, isAction };
59
+ export { defineIntegration, defineMutation, defineQuery, defineWorkflow, isDatasourceAction };
@@ -278,4 +278,4 @@ function withoutTrailingSlash(url) {
278
278
  }
279
279
 
280
280
  //#endregion
281
- export { makePlatform };
281
+ export { makePlatform, platformApiUrl };
@@ -1,24 +1,15 @@
1
+ import { IntegrationAction } from "../action.js";
2
+
1
3
  //#region src/core/integration.d.ts
2
- interface IntegrationFetchOptions {
3
- dataSourceId: string;
4
- url: string;
4
+ interface IntegrationFetchInit {
5
5
  method?: string;
6
6
  headers?: Record<string, string>;
7
7
  body?: string;
8
- queryParams?: Record<string, string>;
9
8
  signal?: AbortSignal;
10
9
  }
11
- declare function integrationFetch({
12
- dataSourceId,
13
- url: rawUrl,
14
- method,
15
- headers,
16
- body,
17
- queryParams,
18
- signal
19
- }: IntegrationFetchOptions): Promise<Response>;
10
+ declare function integrationCreateFetcher(action: IntegrationAction): (url: string, init?: IntegrationFetchInit) => Promise<Response>;
20
11
  declare const integration: {
21
- fetch: typeof integrationFetch;
12
+ createFetcher: typeof integrationCreateFetcher;
22
13
  };
23
14
  //#endregion
24
15
  export { integration };
@@ -2,13 +2,8 @@ import { readSessionToken } from "../auth/session.js";
2
2
  import { dsUrl } from "../datasource/record.js";
3
3
 
4
4
  //#region src/core/integration.ts
5
- async function integrationFetch({ dataSourceId, url: rawUrl, method = "GET", headers = {}, body, queryParams, signal }) {
5
+ async function integrationFetch(action, url, { method = "GET", headers = {}, body, signal } = {}) {
6
6
  if (signal?.aborted) throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
7
- let url = rawUrl;
8
- if (queryParams && Object.keys(queryParams).length > 0) {
9
- const params = new URLSearchParams(queryParams);
10
- url += (rawUrl.includes("?") ? "&" : "?") + params.toString();
11
- }
12
7
  const upperMethod = method.toUpperCase();
13
8
  const payload = {
14
9
  method: upperMethod,
@@ -16,7 +11,7 @@ async function integrationFetch({ dataSourceId, url: rawUrl, method = "GET", hea
16
11
  headers
17
12
  };
18
13
  if (upperMethod !== "GET" && upperMethod !== "HEAD" && body !== void 0) payload.body = body;
19
- return fetch(`${dsUrl(dataSourceId)}/proxy`, {
14
+ return fetch(`${dsUrl(action.resource.datasourceId)}/proxy`, {
20
15
  method: "POST",
21
16
  headers: {
22
17
  "Content-Type": "application/json",
@@ -26,7 +21,10 @@ async function integrationFetch({ dataSourceId, url: rawUrl, method = "GET", hea
26
21
  signal
27
22
  });
28
23
  }
29
- const integration = { fetch: integrationFetch };
24
+ function integrationCreateFetcher(action) {
25
+ return (url, init) => integrationFetch(action, url, init);
26
+ }
27
+ const integration = { createFetcher: integrationCreateFetcher };
30
28
 
31
29
  //#endregion
32
30
  export { integration };
@@ -1,5 +1,8 @@
1
+ import { WorkflowAction } from "../action.js";
2
+
1
3
  //#region src/core/workflow.d.ts
2
- declare function triggerWorkflow(workflowId: string, payload?: Record<string, unknown>): Promise<void>;
4
+ type FlatValue = string | number | boolean | null;
5
+ declare function triggerWorkflow(action: WorkflowAction, payload?: Record<string, FlatValue>): Promise<void>;
3
6
  declare const workflow: {
4
7
  trigger: typeof triggerWorkflow;
5
8
  };
@@ -1,17 +1,31 @@
1
+ import { sessionConfig } from "../auth/config.js";
2
+ import { platformApiUrl } from "../auth/platform.js";
1
3
  import { readSessionToken } from "../auth/session.js";
2
4
 
3
5
  //#region src/core/workflow.ts
4
- const BASE_URL = () => (import.meta.env.VITE_SOFTR_BASE_URL ?? "").replace(/\/$/, "");
5
- async function triggerWorkflow(workflowId, payload) {
6
- const res = await fetch(`${BASE_URL()}/api/workflows/${workflowId}/trigger`, {
6
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
7
+ async function triggerWorkflow(action, payload) {
8
+ const baseUrl = `${await platformApiUrl()}/v1/vibe/applications/${sessionConfig().applicationId}/actions/${action.id}/workflow`;
9
+ const triggerRes = await fetch(baseUrl, {
7
10
  method: "POST",
8
11
  headers: {
9
- "Authorization": `Bearer ${readSessionToken()}`,
10
- ...payload ? { "Content-Type": "application/json" } : {}
12
+ Authorization: `Bearer ${readSessionToken()}`,
13
+ "Content-Type": "application/json"
11
14
  },
12
- ...payload ? { body: JSON.stringify(payload) } : {}
15
+ body: JSON.stringify(payload ?? {})
13
16
  });
14
- if (!res.ok) throw new Error(`Softr workflow trigger failed: ${res.status}`);
17
+ if (!triggerRes.ok) throw new Error(`Workflow trigger failed: ${triggerRes.status}`);
18
+ const execution = await triggerRes.json();
19
+ if (execution.status === "FINISHED") return;
20
+ if (execution.status === "PENDING_INTERACTION") throw new Error("Workflow requires interaction");
21
+ while (true) {
22
+ await sleep(2e3);
23
+ const detailsRes = await fetch(`${baseUrl}/${execution.id}`, { headers: { Authorization: `Bearer ${readSessionToken()}` } });
24
+ if (!detailsRes.ok) throw new Error(`Workflow status check failed: ${detailsRes.status}`);
25
+ const details = await detailsRes.json();
26
+ if (details.status === "FINISHED") return;
27
+ if (details.status === "PENDING_INTERACTION") throw new Error("Workflow requires interaction");
28
+ }
15
29
  }
16
30
  const workflow = { trigger: triggerWorkflow };
17
31
 
@@ -1,4 +1,4 @@
1
- import { Action, FieldTypeOf, ReadShape, SoftrDatasources } from "../action.js";
1
+ import { DatasourceAction, FieldTypeOf, ReadShape, SoftrDatasources } from "../action.js";
2
2
  import { ConstructableOrderBy } from "./filter.js";
3
3
  import { DateFieldType, NumberFieldType, SortSet } from "./fields.js";
4
4
  import { WhereCallback } from "./record.js";
@@ -84,14 +84,14 @@ type Unusable = {
84
84
  * field's value; a date field also carries bucket leaves. A multi-value field has no handle
85
85
  * body, because exploding it would count each record once per value and inflate every metric.
86
86
  */
87
- type DimensionSet<TShape extends ReadShape> = keyof SoftrDatasources extends never ? Record<string, AnyDimension> : { [Alias in keyof TShape["fields"]]: DimensionFor<FieldTypeOf<TShape["datasource"], TShape["fields"][Alias]>> };
87
+ type DimensionSet<TShape extends ReadShape> = keyof SoftrDatasources extends never ? Record<string, AnyDimension> : { [Alias in keyof TShape["fields"]]: DimensionFor<FieldTypeOf<TShape["datasourceId"], TShape["fields"][Alias]>> };
88
88
  type DimensionFor<TFieldType> = [TFieldType] extends [never] ? AnyDimension : TFieldType extends `${string}[]` | "ATTACHMENT" | "DATE_RANGE" ? Unusable : TFieldType extends "DATE" ? DateDimension : TFieldType extends DateFieldType ? DatetimeDimension : TFieldType extends NumberFieldType ? Dimension<number | null> : TFieldType extends "CHECKBOX" ? Dimension<boolean | null> : Dimension<string | null>;
89
89
  /**
90
90
  * The handles a `metrics` callback is handed: aggregation leaves per alias, gated by the
91
91
  * field's declared type, plus `$count`. A field whose aggregate would come back as a
92
92
  * coerced non-number has no leaves at all.
93
93
  */
94
- type MeasureSet<TShape extends ReadShape> = (keyof SoftrDatasources extends never ? Record<string, AnyMeasures> : { [Alias in keyof TShape["fields"]]: MeasureFor<FieldTypeOf<TShape["datasource"], TShape["fields"][Alias]>> }) & {
94
+ type MeasureSet<TShape extends ReadShape> = (keyof SoftrDatasources extends never ? Record<string, AnyMeasures> : { [Alias in keyof TShape["fields"]]: MeasureFor<FieldTypeOf<TShape["datasourceId"], TShape["fields"][Alias]>> }) & {
95
95
  /** Counts the rows in each group. Takes no field: every row counts once. */
96
96
  readonly $count: Measure<number>;
97
97
  };
@@ -181,7 +181,7 @@ type MetricBuilder<TShape extends ReadShape, TValue$1> = {
181
181
  * .orderBy((q) => q.month.asc)
182
182
  * // data: Array<{ month: string | null; revenue: number | null; orders: number }>
183
183
  */
184
- declare function chart<TShape extends ReadShape>(query: Action<TShape>): ChartSeed<TShape>;
184
+ declare function chart<TShape extends ReadShape>(query: DatasourceAction<TShape>): ChartSeed<TShape>;
185
185
  /**
186
186
  * Aggregates records into one value, with no grouping.
187
187
  *
@@ -191,6 +191,6 @@ declare function chart<TShape extends ReadShape>(query: Action<TShape>): ChartSe
191
191
  * .where((q) => q.status.is("paid"))
192
192
  * // value: number | null
193
193
  */
194
- declare function metric<TShape extends ReadShape, TMeasure extends Measure<unknown>>(query: Action<TShape>, pick: (q: MeasureSet<TShape>) => TMeasure): MetricBuilder<TShape, ColumnValue<TMeasure>>;
194
+ declare function metric<TShape extends ReadShape, TMeasure extends Measure<unknown>>(query: DatasourceAction<TShape>, pick: (q: MeasureSet<TShape>) => TMeasure): MetricBuilder<TShape, ColumnValue<TMeasure>>;
195
195
  //#endregion
196
196
  export { chart, metric };
@@ -112,7 +112,7 @@ type Combinators = {
112
112
  * the action never declared has to resolve too, so that case is open by name and permissive
113
113
  * by operator.
114
114
  */
115
- type FieldSet<TShape extends ReadShape> = keyof SoftrDatasources extends never ? Record<string, AnyChain> & Combinators : { [Alias in keyof TShape['fields']]: ChainFor<FieldTypeOf<TShape['datasource'], TShape['fields'][Alias]>, NonNullable<ReadOf<TShape['datasource']>[TShape['fields'][Alias] & keyof ReadOf<TShape['datasource']>]>> } & Combinators;
115
+ type FieldSet<TShape extends ReadShape> = keyof SoftrDatasources extends never ? Record<string, AnyChain> & Combinators : { [Alias in keyof TShape['fields']]: ChainFor<FieldTypeOf<TShape['datasourceId'], TShape['fields'][Alias]>, NonNullable<ReadOf<TShape['datasourceId']>[TShape['fields'][Alias] & keyof ReadOf<TShape['datasourceId']>]>> } & Combinators;
116
116
  /** What an `orderBy` callback is handed: two handles per alias and nothing else. */
117
117
  type SortSet<TRecord> = { [K in keyof TRecord]: {
118
118
  readonly asc: ConstructableOrderBy;
@@ -1,4 +1,4 @@
1
- import { Action, ActionData, ActionRecord, ActionShape, ReadShape } from "../action.js";
1
+ import { ActionData, ActionRecord, ActionShape, DatasourceAction, ReadShape } from "../action.js";
2
2
  import { AnyConstructable, ConstructableOrderBy } from "./filter.js";
3
3
  import { FieldSet, SortSet } from "./fields.js";
4
4
 
@@ -78,7 +78,7 @@ interface ListResult<TRecord extends SoftrRecord = SoftrRecord> {
78
78
  * .orderBy((q) => q.publishedAt.desc)
79
79
  * .limit(50)
80
80
  */
81
- declare function listRecords<TShape extends ReadShape>(query: Action<TShape>): ListBuilder<TShape>;
81
+ declare function listRecords<TShape extends ReadShape>(query: DatasourceAction<TShape>): ListBuilder<TShape>;
82
82
  /**
83
83
  * Gets a single record by ID.
84
84
  *
@@ -94,7 +94,7 @@ declare function listRecords<TShape extends ReadShape>(query: Action<TShape>): L
94
94
  * .get(posts, { id: "record-id" })
95
95
  * .where((q) => q.publishedAt.lte(today))
96
96
  */
97
- declare function getRecord<TShape extends ReadShape>(query: Action<TShape>, options: GetOptions): GetBuilder<TShape>;
97
+ declare function getRecord<TShape extends ReadShape>(query: DatasourceAction<TShape>, options: GetOptions): GetBuilder<TShape>;
98
98
  /**
99
99
  * Creates a record and returns it.
100
100
  *
@@ -105,7 +105,7 @@ declare function getRecord<TShape extends ReadShape>(query: Action<TShape>, opti
105
105
  */
106
106
  declare function createRecord<TShape extends ActionShape & {
107
107
  operation: "create";
108
- }>(action: Action<TShape>, options: {
108
+ }>(action: DatasourceAction<TShape>, options: {
109
109
  data: ActionData<TShape>;
110
110
  }): Promise<SoftrRecord<ActionRecord<TShape>>>;
111
111
  /**
@@ -119,7 +119,7 @@ declare function createRecord<TShape extends ActionShape & {
119
119
  */
120
120
  declare function updateRecord<TShape extends ActionShape & {
121
121
  operation: "update";
122
- }>(action: Action<TShape>, options: {
122
+ }>(action: DatasourceAction<TShape>, options: {
123
123
  id: string;
124
124
  data: ActionData<TShape>;
125
125
  }): Promise<SoftrRecord<ActionRecord<TShape>>>;
@@ -131,7 +131,7 @@ declare function updateRecord<TShape extends ActionShape & {
131
131
  */
132
132
  declare function deleteRecord<TShape extends ActionShape & {
133
133
  operation: "delete";
134
- }>(action: Action<TShape>, options: {
134
+ }>(action: DatasourceAction<TShape>, options: {
135
135
  id: string;
136
136
  }): Promise<void>;
137
137
  //#endregion
@@ -1,5 +1,5 @@
1
1
  import { assert, panic } from "../assertions.js";
2
- import { isAction } from "../action.js";
2
+ import { isDatasourceAction } from "../action.js";
3
3
  import { readUserTimezone } from "../cookies.js";
4
4
  import { readSessionToken } from "../auth/session.js";
5
5
  import { buildFilterCriteria, buildOrderBy, dealiasFilterCriteria, dealiasOrderBy } from "./filter.js";
@@ -233,12 +233,12 @@ async function deleteRecord(action, options) {
233
233
  }
234
234
  function resolveTarget(subject, operation) {
235
235
  const declaredBy = operation === "read" ? "defineQuery" : "defineMutation";
236
- assert(isAction(subject), () => `Expected an action from ${declaredBy} but got ${JSON.stringify(subject)}`);
236
+ assert(isDatasourceAction(subject), () => `Expected an action from ${declaredBy} but got ${JSON.stringify(subject)}`);
237
237
  const action = subject;
238
- assert(action.operation === operation, () => `action "${action.id}" is declared as ${action.operation}, not ${operation}`);
238
+ assert(action.resource.operation === operation, () => `action "${action.id}" is declared as ${action.resource.operation}, not ${operation}`);
239
239
  return {
240
- datasourceId: action.datasource,
241
- select: action.fields ?? {}
240
+ datasourceId: action.resource.datasourceId,
241
+ select: action.resource.fields ?? {}
242
242
  };
243
243
  }
244
244
  function aliasFields(fields, aliases) {
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { SoftrUserGroupId, SoftrUserGroups, User } from "./auth/user.js";
2
- import { SoftrDatasources, defineMutation, defineQuery } from "./action.js";
2
+ import { ActionPermissions, SoftrDatasources, defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
3
3
  import { SoftrContext } from "./auth/context.js";
4
4
  import { defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError } from "./routes/visibility.js";
5
5
  import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser, usePermissions } from "./auth/isomorphic.js";
6
6
  import { useUserTimezone } from "./timezone.js";
7
7
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
8
- export { DefaultErrorOverlay, type SoftrContext, type SoftrDatasources, type SoftrUserGroupId, type SoftrUserGroups, type User, defineMutation, defineQuery, defineVisibility, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, usePermissions, useUserTimezone };
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 };
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { defineMutation, defineQuery } from "./action.js";
1
+ import { defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
2
2
  import { defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError } from "./routes/visibility.js";
3
3
  import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser, usePermissions } from "./auth/isomorphic.js";
4
4
  import { useUserTimezone } from "./timezone.js";
5
5
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
6
6
 
7
- export { DefaultErrorOverlay, defineMutation, defineQuery, defineVisibility, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, usePermissions, useUserTimezone };
7
+ export { DefaultErrorOverlay, 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.30-alpha.0";
2
+ var version = "0.0.31";
3
3
 
4
4
  //#endregion
5
5
  export { version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.30-alpha.0",
3
+ "version": "0.0.31",
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.29
14
+ library_version: 0.0.31
15
15
  ---
16
16
 
17
17
  # Softr SDK core
@@ -21,7 +21,7 @@ metadata:
21
21
  > **CRITICAL**: The SDK is split across four entrypoints and the split is enforced. `ds` and `auth` exist only on `@softr/sdk/server` and only run inside a `createServerFn` handler. Importing them anywhere else fails the build.
22
22
  > **CRITICAL**: Every route file must declare its audience with `defineVisibility`. There is no default and nothing is inherited from a sibling. A route file without a declaration is a lint error.
23
23
  > **CRITICAL**: The app is already wired. `src/start.ts`, `src/router.tsx` and `src/routes/__root.tsx` register the middleware and resolve the session. Do not add a second registration.
24
- > **CRITICAL**: `workflow.trigger` is exploratory. It exists on the entrypoints below and typechecks, but its endpoint is not final. Do not build a feature on it. Tell the user the capability is not ready and ask how they want to proceed.
24
+ > **CRITICAL**: `workflow.trigger` lives on `@softr/sdk/server` and only runs inside a `createServerFn` handler. Use `defineWorkflow` from `@softr/sdk` to declare the action, then pass it to `workflow.trigger`. Load the `softr-sdk/workflows` sub-skill for the full pattern.
25
25
 
26
26
  Each sub-skill stands alone for its task and repeats the boundary above, so most work needs **one** sub-skill and not this file. Load this one when you are unsure which sub-skill applies, or when the wiring below is what you are touching. Add a second sub-skill only when the work genuinely crosses that boundary: a page that lists records behind a group restriction needs `record-queries` plus `routes-and-visibility`.
27
27
 
@@ -43,6 +43,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
43
43
  | Fetch records from a datasource | [record-queries/SKILL.md](./record-queries/SKILL.md) |
44
44
  | Chart, aggregate or count records | [charts-and-metrics/SKILL.md](./charts-and-metrics/SKILL.md) |
45
45
  | Create, update or delete a record | [record-mutations/SKILL.md](./record-mutations/SKILL.md) |
46
+ | Trigger a Softr workflow | [workflows/SKILL.md](./workflows/SKILL.md) |
46
47
  | Sign a visitor in, out, or up | [auth/SKILL.md](./auth/SKILL.md) |
47
48
  | Call a connected third-party API (Slack, Stripe, Gmail, …) | [integrations/SKILL.md](./integrations/SKILL.md) |
48
49
 
@@ -64,6 +65,9 @@ Drawing a chart, a stat tile, or any aggregated number?
64
65
  Submitting a form, editing a record, or deleting one?
65
66
  load softr-sdk/record-mutations
66
67
 
68
+ Triggering a Softr workflow from a button or form?
69
+ load softr-sdk/workflows
70
+
67
71
  Wiring a login, signup, or sign-out?
68
72
  load softr-sdk/auth
69
73
 
@@ -16,7 +16,7 @@ description: >-
16
16
  metadata:
17
17
  type: sub-skill
18
18
  library: softr-sdk
19
- library_version: 0.0.29
19
+ library_version: 0.0.31
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.29
15
+ library_version: 0.0.31
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.
@@ -30,7 +30,7 @@ import { ds } from '@softr/sdk/server'
30
30
 
31
31
  const sales = defineQuery({
32
32
  id: 'sales',
33
- datasource: '00000000-0000-0000-0000-000000000000',
33
+ datasourceId: '00000000-0000-0000-0000-000000000000',
34
34
  fields: {
35
35
  amount: 'Vk9cXm2pSz6Ld4', // CURRENCY
36
36
  region: 'fZk3Qm9pLw2Xa1', // SELECT
@@ -38,7 +38,7 @@ const sales = defineQuery({
38
38
  closedAt: 'Xp2dLh6vKz9Fa3', // DATETIME
39
39
  rep: 'Gq5wNt8rJy3Hb1', // LINKED_RECORD
40
40
  },
41
- permission: { userGroup: 'LOGGED_IN_USERS' },
41
+ permissions: { userGroup: 'LOGGED_IN_USERS' },
42
42
  })
43
43
  ```
44
44
 
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.29
13
+ library_version: 0.0.31
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.
@@ -2,27 +2,27 @@
2
2
  name: integrations
3
3
  description: >-
4
4
  Call a third-party service connected to the app as an integration:
5
- integration.fetch({ dataSourceId, url, method, queryParams, body }) from
6
- @softr/sdk/server, reading the datasource id and integration type from the
7
- "Attached data sources" prompt section, looking up the base URL from the
8
- reference table by integrationType, constructing the URL path, and passing
9
- query params as an object. Check res.ok before res.json().
5
+ defineIntegration from @softr/sdk declares the action with its datasourceId
6
+ and permissions; integration.createFetcher from @softr/sdk/server binds it
7
+ and returns a fetcher function you call with url, optional method, headers,
8
+ and body. Check res.ok before res.json().
10
9
  metadata:
11
10
  type: sub-skill
12
11
  library: softr-sdk
13
- library_version: 0.0.29
12
+ library_version: 0.0.31
14
13
  ---
15
14
 
16
- **Boundary.** `integration` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
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.
17
16
 
18
17
  # Integrations
19
18
 
20
- `integration.fetch` calls a third-party API through Softr's authenticated proxy. It handles authorization automatically — you never set API keys or auth headers yourself.
19
+ `integration.createFetcher` calls a third-party API through Softr's authenticated proxy. It handles authorization automatically — you never set API keys or auth headers yourself.
21
20
 
22
21
  ## Setup
23
22
 
24
23
  ```ts
25
24
  import { createServerFn } from '@tanstack/react-start'
25
+ import { defineIntegration } from '@softr/sdk'
26
26
  import { integration } from '@softr/sdk/server'
27
27
  ```
28
28
 
@@ -34,38 +34,52 @@ 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 the options object passed to `integration.fetch` — that is the `database` coordinate, not the `integration` one. The `integration` value is the OAuth connection identifier; do not pass it to `integration.fetch`. Use `integrationType` (e.g. `GMAIL`) to look up the base URL from the reference table below — then construct the full URL path and pass query parameters as the `queryParams` object.
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.
38
+
39
+ ## Declare the integration action
40
+
41
+ ```ts
42
+ const googleCalendar = defineIntegration({
43
+ id: 'googleCalendar',
44
+ datasourceId: '25d60213-ef4f-4fa0-b094-a46931b2d120',
45
+ permissions: { userGroup: 'LOGGED_IN_USERS' },
46
+ })
47
+ ```
48
+
49
+ Write the object inline in `defineIntegration`. Passing it through a variable widens the types.
38
50
 
39
51
  ## Core pattern
40
52
 
53
+ `integration.createFetcher(action)` binds the action and returns a fetcher function. Call that function with a `url` and optional `method`, `headers`, `body`, and `signal`.
54
+
41
55
  ```ts
42
56
  const getCalendarEvents = createServerFn().handler(async () => {
43
- const res = await integration.fetch({
44
- dataSourceId: '25d60213-ef4f-4fa0-b094-a46931b2d120',
45
- url: 'https://www.googleapis.com/calendar/v3/calendars/primary/events',
46
- queryParams: {
47
- timeMin: new Date().toISOString(),
48
- singleEvents: 'true',
49
- orderBy: 'startTime',
50
- maxResults: '10',
51
- },
52
- })
57
+ const calendar = integration.createFetcher(googleCalendar)
58
+ const now = new Date().toISOString()
59
+ const res = await calendar(
60
+ `https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin=${now}&singleEvents=true&orderBy=startTime&maxResults=10`,
61
+ )
53
62
  if (!res.ok) throw new Error(`Integration request failed: ${res.status} ${await res.text()}`)
54
63
  return res.json()
55
64
  })
56
65
  ```
57
66
 
58
- `dataSourceId` is the `databaseId` from the attached data sources section — the `database` coordinate, not the `integration` one. The base URL (`https://www.googleapis.com`) is looked up from the reference table below by matching `integrationType` (`GOOGLE_CALENDAR`). The path and query params come from your knowledge of the Google Calendar REST API.
67
+ Include query parameters directly in the `url` string — there is no separate `queryParams` option.
59
68
 
60
69
  ## POST request
61
70
 
62
71
  ```ts
72
+ const slackDs = defineIntegration({
73
+ id: 'slack',
74
+ datasourceId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
75
+ permissions: { userGroup: 'LOGGED_IN_USERS' },
76
+ })
77
+
63
78
  const sendSlackMessage = createServerFn({ method: 'POST' })
64
79
  .validator((data: { channel: string; text: string }) => data)
65
80
  .handler(async ({ data }) => {
66
- const res = await integration.fetch({
67
- dataSourceId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
68
- url: 'https://slack.com/api/chat.postMessage',
81
+ const slack = integration.createFetcher(slackDs)
82
+ const res = await slack('https://slack.com/api/chat.postMessage', {
69
83
  method: 'POST',
70
84
  headers: { 'Content-Type': 'application/json' },
71
85
  body: JSON.stringify({ channel: data.channel, text: data.text }),
@@ -141,4 +155,4 @@ If the API the user needs is not in the table above, call it directly from a ser
141
155
 
142
156
  **Setting Authorization headers.** Softr injects credentials automatically. A manually set `Authorization` header overrides the injected one and the request is rejected by the third-party API.
143
157
 
144
- **Not checking `res.ok`.** `integration.fetch` returns the proxied response as-is. A 4xx or 5xx from the third-party API is not thrown — call `res.ok` or `res.status` before calling `res.json()`.
158
+ **Not checking `res.ok`.** The fetcher returns the proxied response as-is. A 4xx or 5xx from the third-party API is not thrown — call `res.ok` or `res.status` before calling `res.json()`.
@@ -11,7 +11,7 @@ description: >-
11
11
  metadata:
12
12
  type: sub-skill
13
13
  library: softr-sdk
14
- library_version: 0.0.29
14
+ library_version: 0.0.31
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.
@@ -38,18 +38,18 @@ A mutation declares exactly one operation, and each of the three needs its own d
38
38
  ```ts
39
39
  const createProject = defineMutation({
40
40
  id: 'createProject',
41
- datasource: '00000000-0000-0000-0000-000000000000',
41
+ datasourceId: '00000000-0000-0000-0000-000000000000',
42
42
  operation: 'create',
43
43
  fields: {
44
44
  name: 'fZk3Qm9pLw2Xa1', // SINGLE_LINE_TEXT
45
45
  status: 'Ru7bVx2cNq0Ed9', // SELECT, choice ids 'draft' | 'active' | 'archived'
46
46
  dueDate: 'Xp2dLh6vKz9Fa3', // DATETIME
47
47
  },
48
- permission: { userGroup: 'LOGGED_IN_USERS' },
48
+ permissions: { userGroup: 'LOGGED_IN_USERS' },
49
49
  })
50
50
  ```
51
51
 
52
- The declaration follows the same rules as a read, spelled out in [record-queries](../record-queries/SKILL.md): `id` reads like a name and is unique across the app, queries counted alongside mutations; `datasource` is the opaque UUID from the table's registration, never derived from its name; the keys of `fields` are names you choose and the values are opaque field ids; `permission` declares who may run the mutation, from the same three choices, and the SDK enforces it; and the object is written inline, because passing it through a variable or a spread widens the field ids and the types stop checking.
52
+ The declaration follows the same rules as a read, spelled out in [record-queries](../record-queries/SKILL.md): `id` reads like a name and is unique across the app, queries counted alongside mutations; `datasourceId` is the opaque UUID from the table's registration, never derived from its name; the keys of `fields` are names you choose and the values are opaque field ids; `permissions` declares who may run the mutation, from the same three choices, and the SDK enforces it; and the object is written inline, because passing it through a variable or a spread widens the field ids and the types stop checking.
53
53
 
54
54
  The mutation's `fields` bound the write: `data` can only name them, and the returned record only carries them. A delete mutation declares no `fields`, and giving it some is a type error.
55
55
 
@@ -76,10 +76,10 @@ Returns the created record as `{ id, fields }`, with `fields` keyed by the mutat
76
76
  ```ts
77
77
  const updateProject = defineMutation({
78
78
  id: 'updateProject',
79
- datasource: '00000000-0000-0000-0000-000000000000',
79
+ datasourceId: '00000000-0000-0000-0000-000000000000',
80
80
  operation: 'update',
81
81
  fields: { status: 'Ru7bVx2cNq0Ed9', dueDate: 'Xp2dLh6vKz9Fa3' },
82
- permission: { userGroup: 'LOGGED_IN_USERS' },
82
+ permissions: { userGroup: 'LOGGED_IN_USERS' },
83
83
  })
84
84
 
85
85
  const archiveProject = createServerFn({ method: 'POST' })
@@ -98,9 +98,9 @@ The record id is its own option; keep it out of `data`. An empty `id` throws.
98
98
  ```ts
99
99
  const deleteProject = defineMutation({
100
100
  id: 'deleteProject',
101
- datasource: '00000000-0000-0000-0000-000000000000',
101
+ datasourceId: '00000000-0000-0000-0000-000000000000',
102
102
  operation: 'delete',
103
- permission: { userGroup: 'LOGGED_IN_USERS' },
103
+ permissions: { userGroup: 'LOGGED_IN_USERS' },
104
104
  })
105
105
 
106
106
  const removeProject = createServerFn({ method: 'POST' })
@@ -119,9 +119,9 @@ An edit form needs both halves: a `defineQuery` to load the record and a `define
119
119
  ```ts
120
120
  const projectDetail = defineQuery({
121
121
  id: 'projectDetail',
122
- datasource: '00000000-0000-0000-0000-000000000000',
122
+ datasourceId: '00000000-0000-0000-0000-000000000000',
123
123
  fields: { status: 'Ru7bVx2cNq0Ed9', dueDate: 'Xp2dLh6vKz9Fa3' },
124
- permission: { userGroup: 'LOGGED_IN_USERS' },
124
+ permissions: { userGroup: 'LOGGED_IN_USERS' },
125
125
  })
126
126
 
127
127
  const project = await ds.get(projectDetail, { id })
@@ -12,7 +12,7 @@ description: >-
12
12
  metadata:
13
13
  type: sub-skill
14
14
  library: softr-sdk
15
- library_version: 0.0.29
15
+ library_version: 0.0.31
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.
@@ -38,21 +38,21 @@ Every read takes a query: which datasource, which fields, who it is for.
38
38
  ```ts
39
39
  const publishedPosts = defineQuery({
40
40
  id: 'publishedPosts',
41
- datasource: '00000000-0000-0000-0000-000000000000',
41
+ datasourceId: '00000000-0000-0000-0000-000000000000',
42
42
  fields: {
43
43
  title: 'fZk3Qm9pLw2Xa1', // SINGLE_LINE_TEXT
44
44
  published: 'Ru7bVx2cNq0Ed9', // CHECKBOX
45
45
  publishedAt: 'Xp2dLh6vKz9Fa3',// DATETIME
46
46
  views: 'mB4kQ8yTn1sWc7', // NUMBER
47
47
  },
48
- permission: { userGroup: 'ALL_USERS' },
48
+ permissions: { userGroup: 'ALL_USERS' },
49
49
  })
50
50
  ```
51
51
 
52
52
  - `id` is the name the person building the app sees, so `publishedPosts`, not `q1`. It must be unique across the app: no two declarations may share one, queries and mutations counted together.
53
- - `datasource` is the 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 is never derived from the table's name.
53
+ - `datasourceId` is the 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 is never derived from the table's name.
54
54
  - The keys of `fields` are names you choose. The values are field ids, opaque strings the table's schema gives you: `get_table` returns them for a Softr table, `list_data_source_table_fields` for a table on a connected database. Everything downstream speaks your names: the returned records, `where`, `orderBy`.
55
- - `permission` declares who may run the query, and the SDK enforces it:
55
+ - `permissions` declares who may run the query, and the SDK enforces it:
56
56
  - `{ userGroup: 'ALL_USERS' }`: everyone, signed out included.
57
57
  - `{ userGroup: 'NON_LOGGED_IN_USERS' }`: signed-out visitors only.
58
58
  - `{ userGroup: 'LOGGED_IN_USERS' }`: any signed-in visitor, narrowed to particular groups with `userGroupIds: ['00000000-0000-0000-0000-00000000000a']`.
@@ -132,9 +132,9 @@ if (first.nextOffset !== null) {
132
132
  ```ts
133
133
  const myInvoices = defineQuery({
134
134
  id: 'myInvoices',
135
- datasource: '00000000-0000-0000-0000-000000000001',
135
+ datasourceId: '00000000-0000-0000-0000-000000000001',
136
136
  fields: { owner: 'Gq5wNt8rJy3Hb1', amount: 'Vk9cXm2pSz6Ld4' },
137
- permission: { userGroup: 'LOGGED_IN_USERS' },
137
+ permissions: { userGroup: 'LOGGED_IN_USERS' },
138
138
  })
139
139
 
140
140
  const listMyInvoices = createServerFn().handler(async ({ context }) => {
@@ -144,7 +144,7 @@ const listMyInvoices = createServerFn().handler(async ({ context }) => {
144
144
  })
145
145
  ```
146
146
 
147
- The SDK enforces `permission`, so a visitor outside the declared audience never reaches the records. Which of those records belong to one visitor is a separate question, and the `where` above answers it: a group says nothing about who owns a row, so a per-visitor read filters on `context.user`. See [current-user](../current-user/SKILL.md) for how the visitor gets onto `context`.
147
+ The SDK enforces `permissions`, so a visitor outside the declared audience never reaches the records. Which of those records belong to one visitor is a separate question, and the `where` above answers it: a group says nothing about who owns a row, so a per-visitor read filters on `context.user`. See [current-user](../current-user/SKILL.md) for how the visitor gets onto `context`.
148
148
 
149
149
  A read the datasource turns down throws rather than coming back empty, and it throws a plain `Error` carrying the status. There is no typed predicate to narrow it with. Handle it where you would handle any other failed fetch: an `errorComponent` on the route, or a check in the handler.
150
150
 
@@ -12,7 +12,7 @@ description: >-
12
12
  metadata:
13
13
  type: sub-skill
14
14
  library: softr-sdk
15
- library_version: 0.0.29
15
+ library_version: 0.0.31
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.
@@ -0,0 +1,115 @@
1
+ ---
2
+ name: workflows
3
+ description: >-
4
+ Trigger a Softr workflow from a server function: defineWorkflow from @softr/sdk
5
+ declares the action with its workflowId, triggerId, permissions, and optional
6
+ hardcoded body; workflow.trigger from @softr/sdk/server fires it with an optional
7
+ flat runtime payload and resolves to void when the workflow finishes.
8
+ metadata:
9
+ type: sub-skill
10
+ library: softr-sdk
11
+ library_version: 0.0.31
12
+ ---
13
+
14
+ **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.
15
+
16
+ # Triggering workflows
17
+
18
+ `workflow.trigger` fires a Softr workflow and waits for it to finish. It resolves to `void`.
19
+
20
+ ## Setup
21
+
22
+ ```ts
23
+ import { createServerFn } from '@tanstack/react-start'
24
+ import { defineWorkflow } from '@softr/sdk'
25
+ import { workflow } from '@softr/sdk/server'
26
+ ```
27
+
28
+ ## Declare the workflow action
29
+
30
+ Every trigger needs a workflow action: which workflow, which trigger step, who may run it, and any data that is always sent.
31
+
32
+ ```ts
33
+ const sendWelcomeEmail = defineWorkflow({
34
+ id: 'sendWelcomeEmail',
35
+ workflowId: 'c9d8e7f6-a5b4-4c3d-8e2f-1a0b9c8d7e6f',
36
+ triggerId: '926da496-749f-4006-999c-197d8a556122',
37
+ permissions: { userGroup: 'LOGGED_IN_USERS' },
38
+ body: {
39
+ email: '{LOGGED_IN_USER:EMAIL}', // resolved server-side from the signed-in user
40
+ name: '{LOGGED_IN_USER:NAME}',
41
+ plan: 'enterprise', // any hardcoded value — always sent, never overridden at runtime
42
+ },
43
+ })
44
+ ```
45
+
46
+ - `id` is a name you choose, unique across the app.
47
+ - `workflowId` and `triggerId` are opaque UUIDs from the workflow definition in the Softr UI. Pass them as inline string literals — never derive them at runtime.
48
+ - `permissions` declares who may run the trigger. Uses the same format as queries and mutations.
49
+ - `body` is hardcoded data the server resolves before calling the workflow. It is **not** passed by the caller at runtime — the workflow always receives it. Template values draw from the signed-in user and the current time (see table below).
50
+
51
+ Write the object inline in `defineWorkflow`. Passing it through a variable widens the types.
52
+
53
+ ## Template values in `body`
54
+
55
+ These placeholders are resolved server-side when the workflow runs:
56
+
57
+ | Value | Resolves to |
58
+ |---|---|
59
+ | `{LOGGED_IN_USER:EMAIL}` | Signed-in user's email |
60
+ | `{LOGGED_IN_USER:NAME}` | Signed-in user's name |
61
+ | `{LOGGED_IN_USER:RECORD_ID}` | Signed-in user's record id |
62
+ | `{LOGGED_IN_USER:<field>}` | Any other field from the user record |
63
+ | `{CURRENT_DATE_TIME}` | Current date and time |
64
+ | `{CURRENT_DATE}` | Current date |
65
+
66
+ ## Core pattern
67
+
68
+ ```ts
69
+ const runWorkflow = createServerFn({ method: 'POST' })
70
+ .validator(z.object({ message: z.string() }))
71
+ .handler(async ({ data }) => {
72
+ await workflow.trigger(sendWelcomeEmail, { message: data.message })
73
+ })
74
+ ```
75
+
76
+ `workflow.trigger` takes the action object and an optional flat runtime payload. The payload only accepts primitive values — `string`, `number`, `boolean`, `null`. Use the action's `body` for anything that is always the same or comes from the user session.
77
+
78
+ `workflow.trigger` resolves only when the workflow has finished.
79
+
80
+ ## Triggering without a runtime payload
81
+
82
+ ```ts
83
+ const run = createServerFn({ method: 'POST' }).handler(async () => {
84
+ await workflow.trigger(sendWelcomeEmail)
85
+ })
86
+ ```
87
+
88
+ Omit the second argument when the action's `body` already carries everything the workflow needs.
89
+
90
+ ## Failure modes
91
+
92
+ **Calling `workflow.trigger` from a component.** `workflow` lives on `@softr/sdk/server`. Wrap it in a `createServerFn`.
93
+
94
+ ```ts
95
+ // Wrong: calling from a click handler
96
+ <button onClick={() => workflow.trigger(sendWelcomeEmail)}>Run</button>
97
+
98
+ // Correct: server function, called from the button
99
+ const run = createServerFn({ method: 'POST' }).handler(() =>
100
+ workflow.trigger(sendWelcomeEmail),
101
+ )
102
+ // …
103
+ const submit = useServerFn(run)
104
+ <button onClick={() => submit()}>Run</button>
105
+ ```
106
+
107
+ **Computing `workflowId` or `triggerId` at runtime.** Both are static UUIDs from the workflow definition. Derive them from user input and the call fails at request time.
108
+
109
+ **Passing nested objects in the runtime payload.** The second argument to `workflow.trigger` only accepts flat values — `string`, `number`, `boolean`, or `null`. Put anything that needs server-side resolution in the action's `body` instead.
110
+
111
+ **Not awaiting the result.** `workflow.trigger` is async. Fire-and-forget loses the result and swallows any error.
112
+
113
+ ## References
114
+
115
+ - [current-user/SKILL.md](../current-user/SKILL.md): reading the visitor off `context` to include them in the payload