@softr/sdk 0.0.68-alpha.1 → 0.0.68
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/auth/current-user-row.d.ts +20 -0
- package/dist/auth/current-user-row.js +22 -0
- package/dist/auth/server.d.ts +2 -0
- package/dist/auth/server.js +3 -1
- package/dist/cli.js +7 -0
- package/dist/codegen/softr-fields.js +41 -0
- package/dist/core/integration.d.ts +0 -7
- package/dist/core/integration.js +3 -10
- package/dist/core/options.d.ts +36 -0
- package/dist/core/options.js +39 -0
- package/dist/core/use-overrides.d.ts +17 -0
- package/dist/core/use-overrides.js +30 -0
- package/dist/datasource/datasource.d.ts +2 -0
- package/dist/datasource/datasource.js +3 -1
- package/dist/datasource/fields.js +3 -2
- package/dist/datasource/record.d.ts +2 -1
- package/dist/fields/format.d.ts +45 -0
- package/dist/fields/format.js +140 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/mcp.d.ts +2 -2
- package/dist/package.js +1 -1
- package/dist/server.d.ts +2 -1
- package/package.json +1 -1
- package/skills/softr-sdk/SKILL.md +2 -6
- package/skills/softr-sdk/auth/SKILL.md +1 -1
- package/skills/softr-sdk/charts-and-metrics/SKILL.md +1 -1
- package/skills/softr-sdk/current-user/SKILL.md +1 -1
- package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
- package/skills/softr-sdk/formatting/SKILL.md +19 -1
- package/skills/softr-sdk/integrations/SKILL.md +5 -58
- package/skills/softr-sdk/mcp/SKILL.md +1 -1
- package/skills/softr-sdk/pwa/SKILL.md +1 -1
- package/skills/softr-sdk/record-mutations/SKILL.md +13 -1
- package/skills/softr-sdk/record-queries/SKILL.md +21 -1
- package/skills/softr-sdk/region-visibility/SKILL.md +1 -1
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +1 -1
- package/skills/softr-sdk/workflows/SKILL.md +1 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { User } from "./user.js";
|
|
2
|
+
import { DatasourceAction, ReadShape } from "../action.js";
|
|
3
|
+
import { GetRecord } from "../datasource/record.js";
|
|
4
|
+
import "@tanstack/react-start/server-only";
|
|
5
|
+
|
|
6
|
+
//#region src/auth/current-user-row.d.ts
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The signed-in visitor's own row of the Users table, found by email, or null when they are signed out
|
|
10
|
+
* or the table holds no row for them. For a "mine" filter, an owner set on create, or a page scoped to
|
|
11
|
+
* what the visitor owns: the row's id is what a link to Users holds.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const me = await auth.currentUserRow(context.user, usersQuery, 'email')
|
|
15
|
+
* if (me === null) return []
|
|
16
|
+
* return ds.list(tasks).where((q) => q.assignee.isOneOf([me.id]))
|
|
17
|
+
*/
|
|
18
|
+
declare function currentUserRow<TShape extends ReadShape>(user: User | null, usersQuery: DatasourceAction<TShape>, email: keyof TShape["fields"] & string): Promise<GetRecord<TShape>>;
|
|
19
|
+
//#endregion
|
|
20
|
+
export { currentUserRow };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { makeChain } from "../datasource/fields.js";
|
|
2
|
+
import { listRecords } from "../datasource/record.js";
|
|
3
|
+
import "@tanstack/react-start/server-only";
|
|
4
|
+
|
|
5
|
+
//#region src/auth/current-user-row.ts
|
|
6
|
+
/**
|
|
7
|
+
* The signed-in visitor's own row of the Users table, found by email, or null when they are signed out
|
|
8
|
+
* or the table holds no row for them. For a "mine" filter, an owner set on create, or a page scoped to
|
|
9
|
+
* what the visitor owns: the row's id is what a link to Users holds.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* const me = await auth.currentUserRow(context.user, usersQuery, 'email')
|
|
13
|
+
* if (me === null) return []
|
|
14
|
+
* return ds.list(tasks).where((q) => q.assignee.isOneOf([me.id]))
|
|
15
|
+
*/
|
|
16
|
+
async function currentUserRow(user, usersQuery, email) {
|
|
17
|
+
if (user === null) return null;
|
|
18
|
+
return (await listRecords(usersQuery).where(() => makeChain(email).is(user.email)).limit(1)).records[0] ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
//#endregion
|
|
22
|
+
export { currentUserRow };
|
package/dist/auth/server.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { checkPasswordLink, requestPasswordReset, setPasswordWithLink } from "./
|
|
|
6
6
|
import { settings } from "./methods/settings.js";
|
|
7
7
|
import { confirmSignUp, signUp } from "./methods/signup.js";
|
|
8
8
|
import { signInWithGoogle } from "./methods/social.js";
|
|
9
|
+
import { currentUserRow } from "./current-user-row.js";
|
|
9
10
|
import "@tanstack/react-start/server-only";
|
|
10
11
|
|
|
11
12
|
//#region src/auth/server.d.ts
|
|
@@ -33,6 +34,7 @@ declare const auth: {
|
|
|
33
34
|
resetAuthenticator: typeof resetAuthenticator;
|
|
34
35
|
removeAuthenticator: typeof removeAuthenticator;
|
|
35
36
|
settings: typeof settings;
|
|
37
|
+
currentUserRow: typeof currentUserRow;
|
|
36
38
|
};
|
|
37
39
|
//#endregion
|
|
38
40
|
export { auth };
|
package/dist/auth/server.js
CHANGED
|
@@ -6,6 +6,7 @@ import { signIn, signOut } from "./methods/password.js";
|
|
|
6
6
|
import { checkPasswordLink, requestPasswordReset, setPasswordWithLink } from "./methods/passwordLink.js";
|
|
7
7
|
import { confirmSignUp, signUp } from "./methods/signup.js";
|
|
8
8
|
import { signInWithGoogle } from "./methods/social.js";
|
|
9
|
+
import { currentUserRow } from "./current-user-row.js";
|
|
9
10
|
import "@tanstack/react-start/server-only";
|
|
10
11
|
|
|
11
12
|
//#region src/auth/server.ts
|
|
@@ -31,7 +32,8 @@ const auth = {
|
|
|
31
32
|
sendAuthenticatorResetCode,
|
|
32
33
|
resetAuthenticator,
|
|
33
34
|
removeAuthenticator,
|
|
34
|
-
settings
|
|
35
|
+
settings,
|
|
36
|
+
currentUserRow
|
|
35
37
|
};
|
|
36
38
|
|
|
37
39
|
//#endregion
|
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { requireDefined } from "./assertions.js";
|
|
|
4
4
|
import { fetchSoftrEnvInput, metadataSourceFromEnv } from "./codegen/fetch-metadata.js";
|
|
5
5
|
import { renderUserGroupsDoc } from "./codegen/user-groups.js";
|
|
6
6
|
import { renderSoftrEnv } from "./codegen/softr-env.js";
|
|
7
|
+
import { renderSoftrFields } from "./codegen/softr-fields.js";
|
|
7
8
|
import { writeIfChanged } from "./codegen/write-if-changed.js";
|
|
8
9
|
import { writeDatasourceDocs } from "./codegen/write-datasource-docs.js";
|
|
9
10
|
import { writeFileSync } from "node:fs";
|
|
@@ -12,6 +13,7 @@ import { parseArgs } from "node:util";
|
|
|
12
13
|
|
|
13
14
|
//#region src/cli.ts
|
|
14
15
|
const OUTPUT = "softr-env.d.ts";
|
|
16
|
+
const FIELDS_OUTPUT = "src/softr-fields.ts";
|
|
15
17
|
const DOCS_DIR = "docs/datasources";
|
|
16
18
|
const USER_GROUPS_DOC = "docs/user-groups.md";
|
|
17
19
|
async function generate({ json }) {
|
|
@@ -19,6 +21,11 @@ async function generate({ json }) {
|
|
|
19
21
|
const input = await fetchSoftrEnvInput(metadataSourceFromEnv());
|
|
20
22
|
writeFileSync(path.resolve(process.cwd(), OUTPUT), renderSoftrEnv(input, version), "utf8");
|
|
21
23
|
const report = writeDocs(input);
|
|
24
|
+
report.docs.push({
|
|
25
|
+
subject: "field shapes",
|
|
26
|
+
path: FIELDS_OUTPUT,
|
|
27
|
+
changed: writeIfChanged(path.resolve(process.cwd(), FIELDS_OUTPUT), renderSoftrFields(input.datasources, version))
|
|
28
|
+
});
|
|
22
29
|
if (json) {
|
|
23
30
|
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
24
31
|
return;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//#region src/codegen/softr-fields.ts
|
|
2
|
+
/**
|
|
3
|
+
* Renders the app's `src/softr-fields.ts`: every registered datasource's fields by id, with the type
|
|
4
|
+
* and options a value's text depends on, registered with the SDK as a side effect of being imported.
|
|
5
|
+
* The root route imports it once, and `formatValue(query, alias, value, formatting)` then formats any
|
|
6
|
+
* value a query read by its field's own type.
|
|
7
|
+
*/
|
|
8
|
+
function renderSoftrFields(datasources, sdkVersion) {
|
|
9
|
+
const entries = [...datasources].sort((a, b) => a.id.localeCompare(b.id)).map((datasource) => {
|
|
10
|
+
const fields = [...datasource.fields].sort((a, b) => a.id.localeCompare(b.id)).map((field) => {
|
|
11
|
+
const shape = { type: field.type };
|
|
12
|
+
if (field.options !== null && field.options !== void 0 && Object.keys(field.options).length > 0) shape.options = field.options;
|
|
13
|
+
if (field.allowMultipleEntries) shape.allowMultipleEntries = true;
|
|
14
|
+
return ` ${JSON.stringify(field.id)}: ${JSON.stringify(shape)},`;
|
|
15
|
+
});
|
|
16
|
+
return [
|
|
17
|
+
` // ${datasource.name}`,
|
|
18
|
+
` ${JSON.stringify(datasource.id)}: {`,
|
|
19
|
+
...fields,
|
|
20
|
+
" },"
|
|
21
|
+
].join("\n");
|
|
22
|
+
});
|
|
23
|
+
return [
|
|
24
|
+
`// Generated by @softr/sdk ${sdkVersion}. Do not edit: the SDK's codegen rewrites it after a schema change.`,
|
|
25
|
+
"//",
|
|
26
|
+
"// The type and options of every field, by datasource and field id, for `formatValue` and",
|
|
27
|
+
"// `fieldShape` from @softr/sdk. Imported once, in the root route, it registers them with the SDK.",
|
|
28
|
+
"import { registerSoftrFields } from \"@softr/sdk\";",
|
|
29
|
+
"import type { SoftrFieldRegistry } from \"@softr/sdk\";",
|
|
30
|
+
"",
|
|
31
|
+
"export const softrFields = {",
|
|
32
|
+
...entries,
|
|
33
|
+
"} as const satisfies SoftrFieldRegistry;",
|
|
34
|
+
"",
|
|
35
|
+
"registerSoftrFields(softrFields);",
|
|
36
|
+
""
|
|
37
|
+
].join("\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
//#endregion
|
|
41
|
+
export { renderSoftrFields };
|
|
@@ -7,13 +7,6 @@ interface IntegrationFetchInit {
|
|
|
7
7
|
headers?: Record<string, string>;
|
|
8
8
|
body?: string;
|
|
9
9
|
signal?: AbortSignal;
|
|
10
|
-
/**
|
|
11
|
-
* Relay the vendor's answer as it is produced instead of waiting for all of it — for any endpoint
|
|
12
|
-
* that streams, such as an LLM completion requested with `"stream": true`. The returned `Response`
|
|
13
|
-
* body is then the vendor's own stream, readable chunk by chunk. A vendor that answers in one piece
|
|
14
|
-
* still works.
|
|
15
|
-
*/
|
|
16
|
-
stream?: boolean;
|
|
17
10
|
}
|
|
18
11
|
declare function integrationCreateFetcher(action: IntegrationAction): (init?: IntegrationFetchInit) => Promise<Response>;
|
|
19
12
|
declare const integration: {
|
package/dist/core/integration.js
CHANGED
|
@@ -3,16 +3,15 @@ import { readSessionToken } from "../auth/session.js";
|
|
|
3
3
|
import { dsUrl, refererHeader } from "../datasource/record.js";
|
|
4
4
|
|
|
5
5
|
//#region src/core/integration.ts
|
|
6
|
-
|
|
7
|
-
async function integrationFetch(action, { params, query, headers = {}, body, signal, stream = false } = {}) {
|
|
6
|
+
async function integrationFetch(action, { params, query, headers = {}, body, signal } = {}) {
|
|
8
7
|
if (signal?.aborted) throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
|
|
9
8
|
const payload = {
|
|
10
9
|
method: action.resource.method,
|
|
11
10
|
url: buildUrl(action.resource.url, params, query),
|
|
12
|
-
headers
|
|
11
|
+
headers
|
|
13
12
|
};
|
|
14
13
|
if (body !== void 0) payload.body = body;
|
|
15
|
-
return fetch(`${dsUrl(action.id)}
|
|
14
|
+
return fetch(`${dsUrl(action.id)}/proxy`, {
|
|
16
15
|
method: "POST",
|
|
17
16
|
headers: {
|
|
18
17
|
"Content-Type": "application/json",
|
|
@@ -31,12 +30,6 @@ function buildUrl(template, params, query) {
|
|
|
31
30
|
if (query !== void 0 && Object.keys(query).length > 0) url += `?${new URLSearchParams(query).toString()}`;
|
|
32
31
|
return url;
|
|
33
32
|
}
|
|
34
|
-
function withAcceptEventStream(headers) {
|
|
35
|
-
return Object.keys(headers).some((name) => name.toLowerCase() === "accept") ? headers : {
|
|
36
|
-
...headers,
|
|
37
|
-
Accept: EVENT_STREAM
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
33
|
function integrationCreateFetcher(action) {
|
|
41
34
|
return (init) => integrationFetch(action, init);
|
|
42
35
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { DatasourceAction, ReadShape } from "../action.js";
|
|
2
|
+
import "@tanstack/react-start/server-only";
|
|
3
|
+
|
|
4
|
+
//#region src/core/options.d.ts
|
|
5
|
+
/** One row a linked-record picker offers. */
|
|
6
|
+
type RecordOption = {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
};
|
|
10
|
+
/** A page of a picker's rows, in the shape `LinkedRecordPicker`'s `load` answers with. */
|
|
11
|
+
type RecordOptions = {
|
|
12
|
+
options: RecordOption[];
|
|
13
|
+
nextOffset: number | null;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The rows a linked field can point at, for a picker: each record of the query as its id and the text
|
|
17
|
+
* of one field, searched on that field when a term is given, a page at a time.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* const loadCompanies = createServerFn()
|
|
21
|
+
* .validator(z.object({ term: z.string(), offset: z.number().nullable() }))
|
|
22
|
+
* .handler(({ data }) => ds.options(companiesQuery, { label: 'name', ...data }))
|
|
23
|
+
*/
|
|
24
|
+
declare function listOptions<TShape extends ReadShape>(query: DatasourceAction<TShape>, {
|
|
25
|
+
label,
|
|
26
|
+
term,
|
|
27
|
+
offset,
|
|
28
|
+
limit
|
|
29
|
+
}: {
|
|
30
|
+
label: keyof TShape["fields"] & string;
|
|
31
|
+
term?: string;
|
|
32
|
+
offset?: number | null;
|
|
33
|
+
limit?: number;
|
|
34
|
+
}): Promise<RecordOptions>;
|
|
35
|
+
//#endregion
|
|
36
|
+
export { RecordOption, RecordOptions, listOptions };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { makeChain } from "../datasource/fields.js";
|
|
2
|
+
import { listRecords } from "../datasource/record.js";
|
|
3
|
+
import "@tanstack/react-start/server-only";
|
|
4
|
+
|
|
5
|
+
//#region src/core/options.ts
|
|
6
|
+
/**
|
|
7
|
+
* The rows a linked field can point at, for a picker: each record of the query as its id and the text
|
|
8
|
+
* of one field, searched on that field when a term is given, a page at a time.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const loadCompanies = createServerFn()
|
|
12
|
+
* .validator(z.object({ term: z.string(), offset: z.number().nullable() }))
|
|
13
|
+
* .handler(({ data }) => ds.options(companiesQuery, { label: 'name', ...data }))
|
|
14
|
+
*/
|
|
15
|
+
async function listOptions(query, { label, term = "", offset = null, limit = 20 }) {
|
|
16
|
+
let list = listRecords(query).limit(limit).offset(offset);
|
|
17
|
+
const needle = term.trim();
|
|
18
|
+
if (needle !== "") list = list.where(() => makeChain(label).contains(needle));
|
|
19
|
+
const page = await list;
|
|
20
|
+
return {
|
|
21
|
+
options: page.records.map((record) => ({
|
|
22
|
+
id: record.id,
|
|
23
|
+
label: labelOf(record.fields[label])
|
|
24
|
+
})),
|
|
25
|
+
nextOffset: page.nextOffset
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** The text a label field holds: its own text, or the label of the choice or record it names. */
|
|
29
|
+
function labelOf(value) {
|
|
30
|
+
if (value === null || value === void 0) return "";
|
|
31
|
+
if (typeof value === "object") {
|
|
32
|
+
const label = value.label;
|
|
33
|
+
return typeof label === "string" ? label : "";
|
|
34
|
+
}
|
|
35
|
+
return String(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
//#endregion
|
|
39
|
+
export { listOptions };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/core/use-overrides.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The edits a page has saved that its loader has not re-run for, keyed by record id: what a drawer or
|
|
4
|
+
* an inline control wrote, merged over the loader's rows until a fresh read replaces them.
|
|
5
|
+
*
|
|
6
|
+
* `onSaved` is the shape a drawer's `onSaved` callback takes: it fires before the write, and again
|
|
7
|
+
* with the old value if the write fails, so the row moves at once and moves back on failure.
|
|
8
|
+
*/
|
|
9
|
+
declare function useOverrides<TPatch extends object>(): {
|
|
10
|
+
/** What is pending for one record, or undefined when nobody has touched it. */
|
|
11
|
+
of: (recordId: string) => TPatch | undefined;
|
|
12
|
+
onSaved: (recordId: string, patch: TPatch) => void;
|
|
13
|
+
/** Drops what is pending for one record, once a fresh read of it has replaced the row. */
|
|
14
|
+
clear: (recordId: string) => void;
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export { useOverrides };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
//#region src/core/use-overrides.ts
|
|
4
|
+
/**
|
|
5
|
+
* The edits a page has saved that its loader has not re-run for, keyed by record id: what a drawer or
|
|
6
|
+
* an inline control wrote, merged over the loader's rows until a fresh read replaces them.
|
|
7
|
+
*
|
|
8
|
+
* `onSaved` is the shape a drawer's `onSaved` callback takes: it fires before the write, and again
|
|
9
|
+
* with the old value if the write fails, so the row moves at once and moves back on failure.
|
|
10
|
+
*/
|
|
11
|
+
function useOverrides() {
|
|
12
|
+
const [pending, setPending] = useState({});
|
|
13
|
+
return {
|
|
14
|
+
of: (recordId) => pending[recordId],
|
|
15
|
+
onSaved: (recordId, patch) => setPending((current) => ({
|
|
16
|
+
...current,
|
|
17
|
+
[recordId]: {
|
|
18
|
+
...current[recordId],
|
|
19
|
+
...patch
|
|
20
|
+
}
|
|
21
|
+
})),
|
|
22
|
+
clear: (recordId) => setPending((current) => ({
|
|
23
|
+
...current,
|
|
24
|
+
[recordId]: void 0
|
|
25
|
+
}))
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
//#endregion
|
|
30
|
+
export { useOverrides };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createRecord, deleteRecord, getRecord, listRecords, updateRecord } from "./record.js";
|
|
2
|
+
import { listOptions } from "../core/options.js";
|
|
2
3
|
import { chart, metric } from "./chart.js";
|
|
3
4
|
import "@tanstack/react-start/server-only";
|
|
4
5
|
|
|
@@ -11,6 +12,7 @@ declare const ds: {
|
|
|
11
12
|
delete: typeof deleteRecord;
|
|
12
13
|
metric: typeof metric;
|
|
13
14
|
chart: typeof chart;
|
|
15
|
+
options: typeof listOptions;
|
|
14
16
|
};
|
|
15
17
|
//#endregion
|
|
16
18
|
export { ds };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createRecord, deleteRecord, getRecord, listRecords, updateRecord } from "./record.js";
|
|
2
2
|
import { chart, metric } from "./chart.js";
|
|
3
|
+
import { listOptions } from "../core/options.js";
|
|
3
4
|
import "@tanstack/react-start/server-only";
|
|
4
5
|
|
|
5
6
|
//#region src/datasource/datasource.ts
|
|
@@ -10,7 +11,8 @@ const ds = {
|
|
|
10
11
|
update: updateRecord,
|
|
11
12
|
delete: deleteRecord,
|
|
12
13
|
metric,
|
|
13
|
-
chart
|
|
14
|
+
chart,
|
|
15
|
+
options: listOptions
|
|
14
16
|
};
|
|
15
17
|
|
|
16
18
|
//#endregion
|
|
@@ -46,7 +46,8 @@ function group(logicalOperator, filters) {
|
|
|
46
46
|
/**
|
|
47
47
|
* One chain per alias, carrying every operator whatever the alias reads as. Only the types
|
|
48
48
|
* narrow: a call the types reject cannot reach here, so there is nothing for a per-type
|
|
49
|
-
* runtime chain to catch.
|
|
49
|
+
* runtime chain to catch. Exported for a filter built on an alias the caller named, where no
|
|
50
|
+
* type stands behind the chain.
|
|
50
51
|
*/
|
|
51
52
|
function makeChain(subject) {
|
|
52
53
|
return {
|
|
@@ -87,4 +88,4 @@ function leaf(subject, operator, value) {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
//#endregion
|
|
90
|
-
export { andAll, makeFieldSet, makeSortSet };
|
|
91
|
+
export { andAll, makeChain, makeFieldSet, makeSortSet };
|
|
@@ -68,6 +68,7 @@ interface ListResult<TRecord extends SoftrRecord = SoftrRecord> {
|
|
|
68
68
|
total: number;
|
|
69
69
|
nextOffset: number | null;
|
|
70
70
|
}
|
|
71
|
+
type GetRecord<TShape extends ReadShape> = SoftrRecord<ActionRecord<TShape>> | null;
|
|
71
72
|
/**
|
|
72
73
|
* Queries a list of records from the datasource.
|
|
73
74
|
*
|
|
@@ -135,4 +136,4 @@ declare function deleteRecord<TShape extends ActionShape & {
|
|
|
135
136
|
id: string;
|
|
136
137
|
}): Promise<void>;
|
|
137
138
|
//#endregion
|
|
138
|
-
export { WhereCallback, createRecord, deleteRecord, getRecord, listRecords, updateRecord };
|
|
139
|
+
export { GetRecord, WhereCallback, createRecord, deleteRecord, getRecord, listRecords, updateRecord };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Formatting } from "../formatting.js";
|
|
2
|
+
|
|
3
|
+
//#region src/fields/format.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A field's type and options, which is all its text depends on. The generated `src/softr-fields.ts`
|
|
7
|
+
* holds one per field of every registered datasource.
|
|
8
|
+
*/
|
|
9
|
+
type FieldShape = {
|
|
10
|
+
type: string;
|
|
11
|
+
options?: Record<string, unknown> | null;
|
|
12
|
+
/** True when the field holds several values, which read back as an array. */
|
|
13
|
+
allowMultipleEntries?: boolean;
|
|
14
|
+
};
|
|
15
|
+
/** The fields of every registered datasource, by datasource id then field id. */
|
|
16
|
+
type SoftrFieldRegistry = Record<string, Record<string, FieldShape>>;
|
|
17
|
+
/**
|
|
18
|
+
* Makes the app's fields known to `fieldShape` and `formatValue`. The generated `src/softr-fields.ts`
|
|
19
|
+
* calls it once as a side effect of being imported; the root route imports that file.
|
|
20
|
+
*/
|
|
21
|
+
declare function registerSoftrFields(fields: SoftrFieldRegistry): void;
|
|
22
|
+
/**
|
|
23
|
+
* The shape of a field a query reads, by the alias the query holds it under. Throws when the app has
|
|
24
|
+
* not registered its fields: the generated `src/softr-fields.ts` is not imported.
|
|
25
|
+
*/
|
|
26
|
+
declare function fieldShape(query: {
|
|
27
|
+
datasourceId: string;
|
|
28
|
+
fields: Record<string, string>;
|
|
29
|
+
}, alias: string): FieldShape;
|
|
30
|
+
/**
|
|
31
|
+
* A value a query read, as the text a page shows it as, by the field's own type and options: money with
|
|
32
|
+
* its symbol, a date in the app's format, a choice or a linked record by its label, a rating out of its
|
|
33
|
+
* scale, a duration in hours and minutes, a checkbox as Yes or No. '' for a value the record does not
|
|
34
|
+
* hold. Several values read joined by a comma.
|
|
35
|
+
*/
|
|
36
|
+
declare function formatValue(query: {
|
|
37
|
+
datasourceId: string;
|
|
38
|
+
fields: Record<string, string>;
|
|
39
|
+
}, alias: string, value: unknown, formatting: Formatting): string;
|
|
40
|
+
/** `formatValue` for a shape in hand, such as one from `src/softr-fields.ts` or a `returnedField`. */
|
|
41
|
+
declare function formatField(shape: FieldShape, value: unknown, formatting: Formatting): string;
|
|
42
|
+
/** A duration, which the datasource holds in milliseconds, as hours and minutes; seconds only under an hour. */
|
|
43
|
+
declare function formatDuration(milliseconds: number): string;
|
|
44
|
+
//#endregion
|
|
45
|
+
export { FieldShape, SoftrFieldRegistry, fieldShape, formatDuration, formatField, formatValue, registerSoftrFields };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { formatDate, formatDateTime, formatNumber } from "../formatting.js";
|
|
2
|
+
|
|
3
|
+
//#region src/fields/format.ts
|
|
4
|
+
let registry = {};
|
|
5
|
+
/**
|
|
6
|
+
* Makes the app's fields known to `fieldShape` and `formatValue`. The generated `src/softr-fields.ts`
|
|
7
|
+
* calls it once as a side effect of being imported; the root route imports that file.
|
|
8
|
+
*/
|
|
9
|
+
function registerSoftrFields(fields) {
|
|
10
|
+
registry = {
|
|
11
|
+
...registry,
|
|
12
|
+
...fields
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The shape of a field a query reads, by the alias the query holds it under. Throws when the app has
|
|
17
|
+
* not registered its fields: the generated `src/softr-fields.ts` is not imported.
|
|
18
|
+
*/
|
|
19
|
+
function fieldShape(query, alias) {
|
|
20
|
+
const fieldId = query.fields[alias];
|
|
21
|
+
if (fieldId === void 0) throw new Error(`The query has no field "${alias}"; it reads ${Object.keys(query.fields).join(", ")}.`);
|
|
22
|
+
const shape = registry[query.datasourceId]?.[fieldId];
|
|
23
|
+
if (shape === void 0) throw new Error(`No shape is registered for field ${fieldId} of datasource ${query.datasourceId}. Import the generated src/softr-fields.ts once, in the root route, and run the SDK's codegen after a schema change.`);
|
|
24
|
+
return shape;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A value a query read, as the text a page shows it as, by the field's own type and options: money with
|
|
28
|
+
* its symbol, a date in the app's format, a choice or a linked record by its label, a rating out of its
|
|
29
|
+
* scale, a duration in hours and minutes, a checkbox as Yes or No. '' for a value the record does not
|
|
30
|
+
* hold. Several values read joined by a comma.
|
|
31
|
+
*/
|
|
32
|
+
function formatValue(query, alias, value, formatting) {
|
|
33
|
+
return formatField(fieldShape(query, alias), value, formatting);
|
|
34
|
+
}
|
|
35
|
+
/** `formatValue` for a shape in hand, such as one from `src/softr-fields.ts` or a `returnedField`. */
|
|
36
|
+
function formatField(shape, value, formatting) {
|
|
37
|
+
if (value === null || value === void 0) return "";
|
|
38
|
+
if (Array.isArray(value)) return value.map((entry) => formatField({
|
|
39
|
+
...shape,
|
|
40
|
+
allowMultipleEntries: false
|
|
41
|
+
}, entry, formatting)).filter((text$1) => text$1 !== "").join(", ");
|
|
42
|
+
const returned = computedReturns(shape);
|
|
43
|
+
if (returned !== null) return formatField(returned, value, formatting);
|
|
44
|
+
const options = shape.options ?? {};
|
|
45
|
+
switch (shape.type) {
|
|
46
|
+
case "CHECKBOX": return value === true ? "Yes" : "No";
|
|
47
|
+
case "NUMBER":
|
|
48
|
+
case "COUNT":
|
|
49
|
+
case "PROGRESS": return typeof value === "number" ? formatNumber(value, formatting, digits(precisionOf(options))) : text(value);
|
|
50
|
+
case "AUTONUMBER": return text(value);
|
|
51
|
+
case "CURRENCY": {
|
|
52
|
+
if (typeof value !== "number") return text(value);
|
|
53
|
+
const code = options.isoCode;
|
|
54
|
+
const precision = precisionOf(options) ?? 2;
|
|
55
|
+
const cents = {
|
|
56
|
+
minimumFractionDigits: Number.isInteger(value) ? 0 : precision,
|
|
57
|
+
maximumFractionDigits: precision
|
|
58
|
+
};
|
|
59
|
+
if (typeof code === "string" && /^[A-Z]{3}$/.test(code)) return formatNumber(value, formatting, {
|
|
60
|
+
style: "currency",
|
|
61
|
+
currency: code,
|
|
62
|
+
...cents
|
|
63
|
+
});
|
|
64
|
+
const amount = formatNumber(value, formatting, cents);
|
|
65
|
+
const symbol = typeof options.customSymbol === "string" ? options.customSymbol : "";
|
|
66
|
+
if (symbol === "") return amount;
|
|
67
|
+
return options.symbolOrCodePosition === "AFTER" ? `${amount} ${symbol}` : `${symbol}${amount}`;
|
|
68
|
+
}
|
|
69
|
+
case "PERCENT": return typeof value === "number" ? `${formatNumber(value, formatting, digits(precisionOf(options)))}%` : text(value);
|
|
70
|
+
case "RATING": return typeof value === "number" ? `${value}/${numberOption(options, "max") ?? 5}` : text(value);
|
|
71
|
+
case "DURATION": return typeof value === "number" ? formatDuration(value) : text(value);
|
|
72
|
+
case "DATE":
|
|
73
|
+
case "DATETIME": return isDateValue(value) ? formatDateTime(value, formatting, { dateStyle: "medium" }) : text(value);
|
|
74
|
+
case "CREATED_AT":
|
|
75
|
+
case "UPDATED_AT":
|
|
76
|
+
if (!isDateValue(value)) return text(value);
|
|
77
|
+
return options.precision === "DATE" ? formatDate(value, formatting, { dateStyle: "medium" }) : formatDateTime(value, formatting, { dateStyle: "medium" });
|
|
78
|
+
case "DATE_RANGE": {
|
|
79
|
+
const range = value;
|
|
80
|
+
return [isDateValue(range.from) ? formatDateTime(range.from, formatting, { dateStyle: "medium" }) : "", isDateValue(range.to) ? formatDateTime(range.to, formatting, { dateStyle: "medium" }) : ""].filter((part) => part !== "").join(" – ");
|
|
81
|
+
}
|
|
82
|
+
case "USER": {
|
|
83
|
+
const person = value;
|
|
84
|
+
return text(person.name ?? person.email);
|
|
85
|
+
}
|
|
86
|
+
case "ATTACHMENT": {
|
|
87
|
+
const file = value;
|
|
88
|
+
return typeof file.filename === "string" && file.filename !== "" ? file.filename : "File";
|
|
89
|
+
}
|
|
90
|
+
default:
|
|
91
|
+
if (typeof value === "object" && "label" in value) return text(value.label);
|
|
92
|
+
return text(value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** The shape a FORMULA, LOOKUP or ROLLUP reads as, when its options say; null for any other field. */
|
|
96
|
+
function computedReturns(shape) {
|
|
97
|
+
if (shape.type !== "FORMULA" && shape.type !== "LOOKUP" && shape.type !== "ROLLUP") return null;
|
|
98
|
+
const returned = shape.options?.returnedField;
|
|
99
|
+
if (typeof returned !== "object" || returned === null || !("type" in returned) || typeof returned.type !== "string") return null;
|
|
100
|
+
const options = "options" in returned && typeof returned.options === "object" ? returned.options : null;
|
|
101
|
+
return {
|
|
102
|
+
type: returned.type,
|
|
103
|
+
options
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function text(value) {
|
|
107
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
108
|
+
}
|
|
109
|
+
function isDateValue(value) {
|
|
110
|
+
return typeof value === "string" && value !== "" || typeof value === "number" || value instanceof Date;
|
|
111
|
+
}
|
|
112
|
+
function numberOption(options, key) {
|
|
113
|
+
const value = options[key];
|
|
114
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
|
|
115
|
+
}
|
|
116
|
+
function precisionOf(options) {
|
|
117
|
+
return numberOption(options, "precision");
|
|
118
|
+
}
|
|
119
|
+
function digits(precision) {
|
|
120
|
+
return precision === void 0 ? void 0 : {
|
|
121
|
+
minimumFractionDigits: precision,
|
|
122
|
+
maximumFractionDigits: precision
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** A duration, which the datasource holds in milliseconds, as hours and minutes; seconds only under an hour. */
|
|
126
|
+
function formatDuration(milliseconds) {
|
|
127
|
+
const total = Math.round(milliseconds / 1e3);
|
|
128
|
+
const hours = Math.floor(total / 3600);
|
|
129
|
+
const minutes = Math.floor(total % 3600 / 60);
|
|
130
|
+
const rest = total % 60;
|
|
131
|
+
const parts = [
|
|
132
|
+
hours > 0 ? `${hours}h` : "",
|
|
133
|
+
minutes > 0 ? `${minutes}m` : "",
|
|
134
|
+
hours === 0 && rest > 0 ? `${rest}s` : ""
|
|
135
|
+
].filter((part) => part !== "");
|
|
136
|
+
return parts.length === 0 ? "0m" : parts.join(" ");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
//#endregion
|
|
140
|
+
export { fieldShape, formatDuration, formatField, formatValue, registerSoftrFields };
|
package/dist/index.d.ts
CHANGED
|
@@ -10,8 +10,10 @@ import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedErr
|
|
|
10
10
|
import { toReturnPath } from "./auth/returnPath.js";
|
|
11
11
|
import { AuthOptions, AuthSettingsProvider, getAuthSettings, getSoftrContext, initialSoftrContext, softrAuthMiddleware, useAuthSettings, useCurrentUser } from "./auth/isomorphic.js";
|
|
12
12
|
import { softrMcpMiddleware } from "./mcp.js";
|
|
13
|
+
import { FieldShape, SoftrFieldRegistry, fieldShape, formatDuration, formatField, formatValue, registerSoftrFields } from "./fields/format.js";
|
|
14
|
+
import { useOverrides } from "./core/use-overrides.js";
|
|
13
15
|
import { useUserTimezone } from "./timezone.js";
|
|
14
16
|
import { PwaInstall, PwaPlatform, SoftrPwaHead, usePwaInstall } from "./pwa.js";
|
|
15
17
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
16
18
|
import { Audience } from "./visibility/audience.js";
|
|
17
|
-
export { type ActionPermissions, Audience, type AuthOptions, type AuthSettings, AuthSettingsProvider, type DateOptions, type DateParts, type DateSeparator, type DateValue, DefaultErrorOverlay, type FileAttachment, type FileItem, type FixedDateFormat, FormattedDate, type Formatting, FormattingScope, type PwaInstall, type PwaPlatform, type SoftrContext, type SoftrDatasources, SoftrPwaHead, type SoftrUserGroupId, type SoftrUserGroups, type User, type WeekDay, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, formatDate, formatDateTime, formatNumber, getAuthSettings, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, toReturnPath, useAuthSettings, useCurrentUser, useFileUpload, useFormatting, usePwaInstall, useUserTimezone };
|
|
19
|
+
export { type ActionPermissions, Audience, type AuthOptions, type AuthSettings, AuthSettingsProvider, type DateOptions, type DateParts, type DateSeparator, type DateValue, DefaultErrorOverlay, type FieldShape, type FileAttachment, type FileItem, type FixedDateFormat, FormattedDate, type Formatting, FormattingScope, type PwaInstall, type PwaPlatform, type SoftrContext, type SoftrDatasources, type SoftrFieldRegistry, SoftrPwaHead, type SoftrUserGroupId, type SoftrUserGroups, type User, type WeekDay, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, fieldShape, formatDate, formatDateTime, formatDuration, formatField, formatNumber, formatValue, getAuthSettings, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, registerSoftrFields, softrAuthMiddleware, softrMcpMiddleware, toReturnPath, useAuthSettings, useCurrentUser, useFileUpload, useFormatting, useOverrides, usePwaInstall, useUserTimezone };
|
package/dist/index.js
CHANGED
|
@@ -7,8 +7,10 @@ import { FormattedDate, FormattingScope, formatDate, formatDateTime, formatNumbe
|
|
|
7
7
|
import { SoftrPwaHead, usePwaInstall } from "./pwa.js";
|
|
8
8
|
import { AuthSettingsProvider, getAuthSettings, getSoftrContext, initialSoftrContext, softrAuthMiddleware, useAuthSettings, useCurrentUser } from "./auth/isomorphic.js";
|
|
9
9
|
import { softrMcpMiddleware } from "./mcp.js";
|
|
10
|
+
import { fieldShape, formatDuration, formatField, formatValue, registerSoftrFields } from "./fields/format.js";
|
|
11
|
+
import { useOverrides } from "./core/use-overrides.js";
|
|
10
12
|
import { useUserTimezone } from "./timezone.js";
|
|
11
13
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
12
14
|
import { Audience } from "./visibility/audience.js";
|
|
13
15
|
|
|
14
|
-
export { Audience, AuthSettingsProvider, DefaultErrorOverlay, FormattedDate, FormattingScope, SoftrPwaHead, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, formatDate, formatDateTime, formatNumber, getAuthSettings, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, toReturnPath, useAuthSettings, useCurrentUser, useFileUpload, useFormatting, usePwaInstall, useUserTimezone };
|
|
16
|
+
export { Audience, AuthSettingsProvider, DefaultErrorOverlay, FormattedDate, FormattingScope, SoftrPwaHead, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, fieldShape, formatDate, formatDateTime, formatDuration, formatField, formatNumber, formatValue, getAuthSettings, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, registerSoftrFields, softrAuthMiddleware, softrMcpMiddleware, toReturnPath, useAuthSettings, useCurrentUser, useFileUpload, useFormatting, useOverrides, usePwaInstall, useUserTimezone };
|
package/dist/mcp.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DatasourceAction } from "./action.js";
|
|
2
|
-
import * as
|
|
2
|
+
import * as _tanstack_react_start4 from "@tanstack/react-start";
|
|
3
3
|
|
|
4
4
|
//#region src/mcp.d.ts
|
|
5
5
|
|
|
@@ -31,6 +31,6 @@ type SoftrMcpOptions = {
|
|
|
31
31
|
* ],
|
|
32
32
|
* }))
|
|
33
33
|
*/
|
|
34
|
-
declare function softrMcpMiddleware(options?: SoftrMcpOptions):
|
|
34
|
+
declare function softrMcpMiddleware(options?: SoftrMcpOptions): _tanstack_react_start4.RequestMiddlewareAfterServer<{}, undefined, undefined>;
|
|
35
35
|
//#endregion
|
|
36
36
|
export { softrMcpMiddleware };
|
package/dist/package.js
CHANGED
package/dist/server.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { upload } from "./core/file-upload.js";
|
|
2
2
|
import { auth } from "./auth/server.js";
|
|
3
|
+
import { RecordOption, RecordOptions } from "./core/options.js";
|
|
3
4
|
import { ds } from "./datasource/datasource.js";
|
|
4
5
|
import { workflow } from "./core/workflow.js";
|
|
5
6
|
import { integration } from "./core/integration.js";
|
|
6
7
|
import "@tanstack/react-start/server-only";
|
|
7
|
-
export { auth, ds, integration, upload, workflow };
|
|
8
|
+
export { type RecordOption, type RecordOptions, auth, ds, integration, upload, workflow };
|
package/package.json
CHANGED
|
@@ -12,7 +12,7 @@ description: >-
|
|
|
12
12
|
metadata:
|
|
13
13
|
type: core
|
|
14
14
|
library: softr-sdk
|
|
15
|
-
library_version: 0.0.68
|
|
15
|
+
library_version: 0.0.68
|
|
16
16
|
---
|
|
17
17
|
|
|
18
18
|
# Softr SDK core
|
|
@@ -30,7 +30,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
|
|
|
30
30
|
|
|
31
31
|
| Import | Runs | Holds |
|
|
32
32
|
| --- | --- | --- |
|
|
33
|
-
| `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineVisibility`, `guardRoute`, `assertRouteAccess`, `isAuthorized`, `checkAccess`, `Audience`, `useCurrentUser`, `useUserTimezone`, `FormattedDate`, `FormattingScope`, `useFormatting`, `formatDate`, `formatDateTime`, `formatNumber`, `SoftrPwaHead`, `usePwaInstall`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
|
|
33
|
+
| `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineVisibility`, `guardRoute`, `assertRouteAccess`, `isAuthorized`, `checkAccess`, `Audience`, `useCurrentUser`, `useUserTimezone`, `FormattedDate`, `FormattingScope`, `useFormatting`, `formatDate`, `formatDateTime`, `formatNumber`, `formatValue`, `formatField`, `fieldShape`, `registerSoftrFields`, `useOverrides`, `SoftrPwaHead`, `usePwaInstall`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
|
|
34
34
|
| `@softr/sdk/server` | Server only | `ds`, `auth`, `workflow`, `integration` |
|
|
35
35
|
| `@softr/sdk/client` | Browser only | Nothing yet, reserved for browser-only utilities |
|
|
36
36
|
| `@softr/sdk/vite` | Build | `softr()`, already in `vite.config.ts` |
|
|
@@ -48,7 +48,6 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
|
|
|
48
48
|
| Trigger a Softr workflow | [workflows/SKILL.md](./workflows/SKILL.md) |
|
|
49
49
|
| Sign a visitor in, out, or up | [auth/SKILL.md](./auth/SKILL.md) |
|
|
50
50
|
| Call a connected third-party API (Slack, Stripe, Gmail, …) | [integrations/SKILL.md](./integrations/SKILL.md) |
|
|
51
|
-
| Show an AI answer as it is written (a chat, an assistant, a live summary) | [integrations/SKILL.md](./integrations/SKILL.md), section "Streaming a vendor answer" |
|
|
52
51
|
| Open the app to AI assistants over MCP | [mcp/SKILL.md](./mcp/SKILL.md) |
|
|
53
52
|
| Let visitors install the app on their home screen | [pwa/SKILL.md](./pwa/SKILL.md) |
|
|
54
53
|
| Show a date, a time or a number | [formatting/SKILL.md](./formatting/SKILL.md) |
|
|
@@ -83,9 +82,6 @@ Wiring a login, signup, or sign-out?
|
|
|
83
82
|
Calling a connected third-party API (Slack, Stripe, Gmail, Google Calendar, …)?
|
|
84
83
|
load softr-sdk/integrations
|
|
85
84
|
|
|
86
|
-
Showing an AI answer as it is written: a chat, an assistant, a summary that types itself out?
|
|
87
|
-
load softr-sdk/integrations and stream the call: its "Streaming a vendor answer" section
|
|
88
|
-
|
|
89
85
|
Opening the app to an AI assistant over MCP?
|
|
90
86
|
load softr-sdk/mcp
|
|
91
87
|
|
|
@@ -19,7 +19,7 @@ description: >-
|
|
|
19
19
|
metadata:
|
|
20
20
|
type: sub-skill
|
|
21
21
|
library: softr-sdk
|
|
22
|
-
library_version: 0.0.68
|
|
22
|
+
library_version: 0.0.68
|
|
23
23
|
---
|
|
24
24
|
|
|
25
25
|
**Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build.
|
|
@@ -12,7 +12,7 @@ description: >-
|
|
|
12
12
|
metadata:
|
|
13
13
|
type: sub-skill
|
|
14
14
|
library: softr-sdk
|
|
15
|
-
library_version: 0.0.68
|
|
15
|
+
library_version: 0.0.68
|
|
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.
|
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.68
|
|
13
|
+
library_version: 0.0.68
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
**Boundary.** `upload` lives on `@softr/sdk/server` and runs inside server functions. `useFileUpload` and `FileItem` live on `@softr/sdk` and run in components. `FileAttachment` comes from `@softr/sdk` and can be imported anywhere.
|
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.68
|
|
13
|
+
library_version: 0.0.68
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
# Dates, times and numbers
|
|
@@ -116,6 +116,24 @@ The currency comes from the field or from the user, never from the locale. A Ger
|
|
|
116
116
|
|
|
117
117
|
`formatDate` and `formatDateTime` show a timestamp in UTC while `formatting.timeZone` is `null`. In JSX, prefer `<FormattedDate />`, which waits instead.
|
|
118
118
|
|
|
119
|
+
### A field's own text: `formatValue`
|
|
120
|
+
|
|
121
|
+
A value a query read already knows how it should look: money with its symbol and cents, a percent, a rating out of its scale, a duration in hours and minutes, a date in the app's format, a choice or a linked record by its label, a checkbox as Yes or No, several values joined by a comma, `''` for nothing. `formatValue` reads the field's type and options from the generated `src/softr-fields.ts` and does all of it, so a page never branches on a type itself.
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
import { formatValue, useFormatting } from '@softr/sdk'
|
|
125
|
+
import { dealsQuery } from '#/server/deals.ts'
|
|
126
|
+
|
|
127
|
+
function Amount({ deal }: { deal: Deal }) {
|
|
128
|
+
const formatting = useFormatting()
|
|
129
|
+
return <span>{formatValue(dealsQuery, 'amount', deal.fields.amount, formatting)}</span>
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The second argument is the alias the query holds the field under. `formatField(shape, value, formatting)` does the same for a shape in hand, and `fieldShape(query, alias)` reads one.
|
|
134
|
+
|
|
135
|
+
The SDK's codegen writes `src/softr-fields.ts` beside `softr-env.d.ts` after every schema change; it registers the shapes as a side effect of being imported, and the root route imports it once. `formatValue` throws when that import is missing.
|
|
136
|
+
|
|
119
137
|
### Outside render: a server function or `beforeLoad`
|
|
120
138
|
|
|
121
139
|
Not every date ends up in a component. A CSV or spreadsheet export, a text the server sends, or a value a loader prepares are formatted before React runs. There, `context.formatting` holds the same `Formatting` that `useFormatting()` returns. See [current-user](../current-user/SKILL.md) for how `context` reaches a handler.
|
|
@@ -5,12 +5,12 @@ description: >-
|
|
|
5
5
|
defineIntegration from @softr/sdk declares the action with its integrationId,
|
|
6
6
|
the one HTTP method and endpoint url it allows, and permissions;
|
|
7
7
|
integration.createFetcher from @softr/sdk/server binds it and returns a
|
|
8
|
-
fetcher function you call with optional params, query, headers, body
|
|
9
|
-
|
|
8
|
+
fetcher function you call with optional params, query, headers, and body.
|
|
9
|
+
Check res.ok before res.json().
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.68
|
|
13
|
+
library_version: 0.0.68
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
**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.
|
|
@@ -129,15 +129,9 @@ Do not add an `Authorization` key to `headers` — Softr injects the integration
|
|
|
129
129
|
|
|
130
130
|
## Calling an AI model
|
|
131
131
|
|
|
132
|
-
**Two shapes, decided by who reads the answer.** An answer the user reads as it is written — a chat, an
|
|
133
|
-
assistant, a long summary shown on the page — streams: pass `stream: true` and relay the body, see
|
|
134
|
-
[Streaming a vendor answer](#streaming-a-vendor-answer) below. An answer the server reads — JSON the page
|
|
135
|
-
lays out, text saved to a field — is buffered: the call below, then `res.json()`. A chat is never buffered:
|
|
136
|
-
the user would watch a blank panel until the last token.
|
|
137
|
-
|
|
138
132
|
A button on one record that hands its fields to the model and shows the answer is the `ai-action` pattern:
|
|
139
|
-
it carries
|
|
140
|
-
a field the model fills on create
|
|
133
|
+
it carries this call and the model id, so write the call by hand only for another shape — a batch, a chat,
|
|
134
|
+
a field the model fills on create. An AI integration is called like any other. For `ANTHROPIC_AI`, the action is `POST` on
|
|
141
135
|
`https://api.anthropic.com/v1/messages`, the request carries the `anthropic-version` header and a body of
|
|
142
136
|
`model`, `max_tokens`, `system` and `messages`, and the answer's text is in `content[].text`:
|
|
143
137
|
|
|
@@ -170,49 +164,6 @@ const text = body.content.flatMap((block) => (block.type === 'text' && block.tex
|
|
|
170
164
|
the button is pressed. `claude-opus-5-5` is the current default; `claude-sonnet-5-5` is the cheaper one for a
|
|
171
165
|
short summary.
|
|
172
166
|
|
|
173
|
-
## Streaming a vendor answer
|
|
174
|
-
|
|
175
|
-
Some vendor endpoints can send their answer in pieces while they produce it — LLM completions token by token, long exports, live event feeds. The fetcher relays such an answer as it arrives when you pass `stream: true`; the response body is then the vendor's own stream, unchanged.
|
|
176
|
-
|
|
177
|
-
Every connected LLM streams its completion. Ask the vendor for it in the request **and** pass `stream: true` to the fetcher; one without the other gains nothing:
|
|
178
|
-
|
|
179
|
-
| integrationType | Endpoint | How to ask for the stream |
|
|
180
|
-
|---|---|---|
|
|
181
|
-
| ANTHROPIC_AI | `POST /v1/messages` | `"stream": true` in the body |
|
|
182
|
-
| OPEN_AI | `POST /v1/chat/completions` or `POST /v1/responses` | `"stream": true` in the body |
|
|
183
|
-
| GEMINI_AI | `POST /v1beta/models/{model}:streamGenerateContent` with `query: { alt: 'sse' }` | the endpoint itself; `generateContent` never streams, so the action's `url` must name the streaming one |
|
|
184
|
-
| MISTRAL_AI | `POST /v1/chat/completions` | `"stream": true` in the body |
|
|
185
|
-
| DEEP_SEEK_AI | `POST /chat/completions` | `"stream": true` in the body |
|
|
186
|
-
| COHERE | `POST /v2/chat` | `"stream": true` in the body |
|
|
187
|
-
|
|
188
|
-
For any other vendor, read that endpoint's docs: it streams when they offer a `stream` flag, an `Accept: text/event-stream` variant, or a chunked response to read incrementally. Most REST calls answer in one piece; call those without `stream` and `res.json()` works.
|
|
189
|
-
|
|
190
|
-
Hand a streamed body to the browser as a `Response` from the server function and read it there. The example below streams a completion through the `askClaude` action declared above; the same shape applies to any vendor endpoint that streams.
|
|
191
|
-
|
|
192
|
-
```ts
|
|
193
|
-
const streamAnswer = createServerFn({ method: 'POST' })
|
|
194
|
-
.validator((data: { prompt: string }) => data)
|
|
195
|
-
.handler(async ({ data }) => {
|
|
196
|
-
const claude = integration.createFetcher(askClaude)
|
|
197
|
-
const res = await claude({
|
|
198
|
-
stream: true,
|
|
199
|
-
headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
|
|
200
|
-
body: JSON.stringify({
|
|
201
|
-
model: 'claude-opus-5-5',
|
|
202
|
-
max_tokens: 1024,
|
|
203
|
-
stream: true,
|
|
204
|
-
messages: [{ role: 'user', content: data.prompt }],
|
|
205
|
-
}),
|
|
206
|
-
})
|
|
207
|
-
if (!res.ok) throw new Error(`AI error: ${res.status} ${await res.text()}`)
|
|
208
|
-
return new Response(res.body, { headers: { 'Content-Type': 'text/event-stream' } })
|
|
209
|
-
})
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
On the client, call the server function's URL with `fetch` and read `response.body` with a `ReadableStream` reader; each chunk arrives in the vendor's own format, for an event stream one or more `event:`/`data:` frames. Use `stream: true` only together with a request that asks the vendor to stream: without it the vendor answers in one piece, which still works but gains nothing.
|
|
213
|
-
|
|
214
|
-
`stream: true` is not available for REST_API integrations; those always answer in one piece.
|
|
215
|
-
|
|
216
167
|
## Base URLs by integration type
|
|
217
168
|
|
|
218
169
|
Match the `integrationType` from the attached integrations section to find the base URL:
|
|
@@ -284,7 +235,3 @@ If the API the user needs is not in the table above, call it directly from a ser
|
|
|
284
235
|
**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()`.
|
|
285
236
|
|
|
286
237
|
**Reusing one action for a different endpoint.** An action authorizes one `method` and one endpoint `url`. A different endpoint belongs to a different action — declare one for it, with its own audience.
|
|
287
|
-
|
|
288
|
-
**Buffering an answer the user watches being written.** A chat or assistant called without `stream: true` shows nothing until the last token arrives, and a long answer then hits the proxy's read timeout. Pass `stream: true`, ask the vendor to stream in the request, and return `res.body` in a `Response`.
|
|
289
|
-
|
|
290
|
-
**Reading a streamed body with `res.json()`.** With `stream: true` the body is an event stream, not one JSON document. Return `res.body` in a `Response` or read it with a stream reader; `res.json()` waits for the end and then fails to parse.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.68
|
|
12
|
+
library_version: 0.0.68
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `softrMcpMiddleware` lives on `@softr/sdk` and is registered once, in `src/start.ts`. The actions you hand it are the same `defineQuery` / `defineMutation` values the app's own pages use — declared wherever they already live, not duplicated for MCP.
|
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
metadata:
|
|
12
12
|
type: sub-skill
|
|
13
13
|
library: softr-sdk
|
|
14
|
-
library_version: 0.0.68
|
|
14
|
+
library_version: 0.0.68
|
|
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.
|
|
@@ -135,6 +135,18 @@ await ds.update(updateProject, {
|
|
|
135
135
|
|
|
136
136
|
A read answers a SELECT as `{ id, label }` and a write takes `{ id }`, so pass the id through rather than the whole value.
|
|
137
137
|
|
|
138
|
+
### Show the edit before the loader re-runs
|
|
139
|
+
|
|
140
|
+
A list whose rows a drawer or an inline control edits keeps the saved values over the loader's rows until a fresh read replaces them. `useOverrides` holds them by record id:
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
const overrides = useOverrides<Partial<Deal['fields']>>()
|
|
144
|
+
const rows = deals.map((deal) => ({ ...deal, fields: { ...deal.fields, ...overrides.of(deal.id) } }))
|
|
145
|
+
<DealsDrawer onSaved={overrides.onSaved} />
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`onSaved(recordId, patch)` is what a drawer calls before the write and again with the old value if the write fails; `clear(recordId)` drops a record's patch once the page has reloaded it.
|
|
149
|
+
|
|
138
150
|
### Form dropdowns for linked record fields
|
|
139
151
|
|
|
140
152
|
A form select for a `LINKED_RECORD` field needs its options from the linked table, not from the record being edited. Query the linked table with its own `defineQuery` and `ds.list`; each result's `id` is what you pass to the write payload. See [record-queries](../record-queries/SKILL.md).
|
|
@@ -13,7 +13,7 @@ description: >-
|
|
|
13
13
|
metadata:
|
|
14
14
|
type: sub-skill
|
|
15
15
|
library: softr-sdk
|
|
16
|
-
library_version: 0.0.68
|
|
16
|
+
library_version: 0.0.68
|
|
17
17
|
---
|
|
18
18
|
|
|
19
19
|
**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. The same holds for any helper that calls `ds`: declare it inside the handler. A function at module level that calls `ds` keeps the server-only import in the browser bundle, and every page stops loading until it moves. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -146,6 +146,18 @@ const listMyInvoices = createServerFn().handler(async ({ context }) => {
|
|
|
146
146
|
})
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
+
When the owner is a link to the Users table, the row to match is the visitor's own row there, not `context.user.id`. `auth.currentUserRow(context.user, usersQuery, 'email')` finds it by email, or answers `null` when they are signed out or have no row:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { auth, ds } from '@softr/sdk/server'
|
|
153
|
+
|
|
154
|
+
const listMyDeals = createServerFn().handler(async ({ context }) => {
|
|
155
|
+
const me = await auth.currentUserRow(context.user, usersQuery, 'email')
|
|
156
|
+
if (me === null) return { records: [], nextOffset: null }
|
|
157
|
+
return await ds.list(deals).where((q) => q.owner.isOneOf([me.id]))
|
|
158
|
+
})
|
|
159
|
+
```
|
|
160
|
+
|
|
149
161
|
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`.
|
|
150
162
|
|
|
151
163
|
### Populate a control from a linked table
|
|
@@ -178,6 +190,14 @@ Each returned record is one option: `record.id` is the value a `where` filter an
|
|
|
178
190
|
ds.list(products).where((q) => q.status.is(selectedStatusId))
|
|
179
191
|
```
|
|
180
192
|
|
|
193
|
+
For a picker that searches and pages, `ds.options` answers the shape a linked-record picker's `load` takes, `{ options: [{ id, label }], nextOffset }`, searched on the label field when a term is given:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
const loadProductStatuses = createServerFn()
|
|
197
|
+
.validator(z.object({ term: z.string(), offset: z.number().nullable() }))
|
|
198
|
+
.handler(({ data }) => ds.options(productStatuses, { label: 'name', ...data }))
|
|
199
|
+
```
|
|
200
|
+
|
|
181
201
|
Skip this request when the options are predefined and fixed — hardcode them instead.
|
|
182
202
|
|
|
183
203
|
A read the datasource turns down throws rather than coming back empty, and it throws a plain `Error` carrying the status, followed by the reason the datasource gives when it sends one. 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.
|
|
@@ -13,7 +13,7 @@ description: >-
|
|
|
13
13
|
metadata:
|
|
14
14
|
type: sub-skill
|
|
15
15
|
library: softr-sdk
|
|
16
|
-
library_version: 0.0.68
|
|
16
|
+
library_version: 0.0.68
|
|
17
17
|
---
|
|
18
18
|
|
|
19
19
|
**Boundary.** `defineVisibility`, the access functions and the error predicates come from `@softr/sdk` and work anywhere.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.68
|
|
12
|
+
library_version: 0.0.68
|
|
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.
|