@softr/sdk 0.0.41 → 0.0.43
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/cli.js +4 -0
- package/dist/codegen/datasource-docs.js +318 -0
- package/dist/codegen/datasource-types.js +22 -5
- package/dist/codegen/doc-file-names.js +31 -0
- package/dist/codegen/softr-env.js +4 -1
- package/dist/codegen/write-datasource-docs.js +24 -0
- package/dist/package.js +1 -1
- package/package.json +1 -1
- package/skills/softr-sdk/SKILL.md +1 -1
- 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 +7 -6
- package/skills/softr-sdk/integrations/SKILL.md +5 -1
- package/skills/softr-sdk/record-mutations/SKILL.md +1 -1
- package/skills/softr-sdk/record-queries/SKILL.md +2 -3
- package/skills/softr-sdk/record-queries/references/field-types.md +2 -2
- package/skills/softr-sdk/record-queries/references/filters-and-sorting.md +1 -1
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +4 -4
- package/skills/softr-sdk/workflows/SKILL.md +5 -3
package/dist/cli.js
CHANGED
|
@@ -2,15 +2,19 @@
|
|
|
2
2
|
import { version } from "./package.js";
|
|
3
3
|
import { fetchSoftrEnvInput, metadataSourceFromEnv } from "./codegen/fetch-metadata.js";
|
|
4
4
|
import { renderSoftrEnv } from "./codegen/softr-env.js";
|
|
5
|
+
import { writeDatasourceDocs } from "./codegen/write-datasource-docs.js";
|
|
5
6
|
import { writeFileSync } from "node:fs";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
|
|
8
9
|
//#region src/cli.ts
|
|
9
10
|
const OUTPUT = "softr-env.d.ts";
|
|
11
|
+
const DOCS_DIR = "docs/datasources";
|
|
10
12
|
async function generate() {
|
|
11
13
|
const input = await fetchSoftrEnvInput(metadataSourceFromEnv());
|
|
12
14
|
writeFileSync(path.resolve(process.cwd(), OUTPUT), renderSoftrEnv(input, version), "utf8");
|
|
13
15
|
console.log(`[softr] wrote ${OUTPUT} (${input.datasources.length} datasources)`);
|
|
16
|
+
writeDatasourceDocs(input.datasources, path.resolve(process.cwd(), DOCS_DIR));
|
|
17
|
+
console.log(`[softr] wrote ${DOCS_DIR} (${input.datasources.length} datasources)`);
|
|
14
18
|
}
|
|
15
19
|
const [command] = process.argv.slice(2);
|
|
16
20
|
if (command === "generate") await generate();
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { assert, requireDefined } from "../assertions.js";
|
|
2
|
+
import { choiceEntries, fieldTypeLiteral, isWritable, option, readExample, resolvedShape, scalarExample, underlyingType } from "./datasource-types.js";
|
|
3
|
+
|
|
4
|
+
//#region src/codegen/datasource-docs.ts
|
|
5
|
+
const DOC_NOTE = "> This document is generated automatically when a change is made in the datasource.";
|
|
6
|
+
const INDEX_NOTE = "> These documents are generated automatically when a change is made in a datasource.";
|
|
7
|
+
function renderDatasourceDoc(datasource, context) {
|
|
8
|
+
return [
|
|
9
|
+
`# ${datasourceLabel(datasource)}`,
|
|
10
|
+
"",
|
|
11
|
+
...summary(datasource, context),
|
|
12
|
+
"",
|
|
13
|
+
DOC_NOTE,
|
|
14
|
+
"",
|
|
15
|
+
"## Field reference",
|
|
16
|
+
"",
|
|
17
|
+
...orderedFields(datasource).flatMap((field) => [...renderField(field, datasource, context), ""]),
|
|
18
|
+
...renderExamples(datasource, context.datasources)
|
|
19
|
+
].join("\n");
|
|
20
|
+
}
|
|
21
|
+
/** Two trailing spaces are a markdown hard break, which keeps the block one paragraph. */
|
|
22
|
+
function summary(datasource, context) {
|
|
23
|
+
const lines = [`Datasource ID: ${datasource.id}`];
|
|
24
|
+
const related = relatedLinks(datasource, context);
|
|
25
|
+
if (related.length > 0) {
|
|
26
|
+
const label = related.length === 1 ? "datasource" : "datasources";
|
|
27
|
+
lines.push(`Related ${label}: ${related.join(", ")}`);
|
|
28
|
+
}
|
|
29
|
+
return lines.map((line, index) => index === lines.length - 1 ? line : `${line} `);
|
|
30
|
+
}
|
|
31
|
+
function relatedLinks(datasource, context) {
|
|
32
|
+
const seen = /* @__PURE__ */ new Set();
|
|
33
|
+
const links = [];
|
|
34
|
+
for (const field of datasource.fields) {
|
|
35
|
+
const linkedId = option(field.options, "linkedTableId");
|
|
36
|
+
if (typeof linkedId !== "string" || seen.has(linkedId)) continue;
|
|
37
|
+
seen.add(linkedId);
|
|
38
|
+
const linked = context.datasources.get(linkedId);
|
|
39
|
+
if (linked === void 0 || linked.id === datasource.id) continue;
|
|
40
|
+
links.push(docLink(linked, context));
|
|
41
|
+
}
|
|
42
|
+
return links;
|
|
43
|
+
}
|
|
44
|
+
function docLink(datasource, context) {
|
|
45
|
+
const file = requireDefined(context.fileNames.get(datasource.id), `No doc file name for datasource ${datasource.id}`);
|
|
46
|
+
return `[${datasourceLabel(datasource)}](./${file})`;
|
|
47
|
+
}
|
|
48
|
+
function renderDatasourceIndex(datasources, context) {
|
|
49
|
+
const heading = [
|
|
50
|
+
"# Datasources",
|
|
51
|
+
"",
|
|
52
|
+
INDEX_NOTE,
|
|
53
|
+
""
|
|
54
|
+
];
|
|
55
|
+
if (datasources.length === 0) return [
|
|
56
|
+
...heading,
|
|
57
|
+
"This app has no registered datasources.",
|
|
58
|
+
""
|
|
59
|
+
].join("\n");
|
|
60
|
+
return [
|
|
61
|
+
...heading,
|
|
62
|
+
"Datasources available in this app:",
|
|
63
|
+
"",
|
|
64
|
+
...datasources.map((datasource) => {
|
|
65
|
+
const file = requireDefined(context.fileNames.get(datasource.id), `No doc file name for datasource ${datasource.id}`);
|
|
66
|
+
return `- ${datasourceLabel(datasource)} (${datasource.id}), [${file}](./${file})`;
|
|
67
|
+
}),
|
|
68
|
+
""
|
|
69
|
+
].join("\n");
|
|
70
|
+
}
|
|
71
|
+
function orderedFields(datasource) {
|
|
72
|
+
const primary = datasource.fields.filter((field) => field.id === datasource.primaryFieldId);
|
|
73
|
+
const rest = datasource.fields.filter((field) => field.id !== datasource.primaryFieldId);
|
|
74
|
+
return [...primary, ...rest];
|
|
75
|
+
}
|
|
76
|
+
function renderField(field, datasource, context) {
|
|
77
|
+
return [
|
|
78
|
+
displayName(field.name),
|
|
79
|
+
"",
|
|
80
|
+
...detail(field, datasource, context)
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
/** The options that change what a value means or what a valid one looks like, and no others. */
|
|
84
|
+
const DETAILED_OPTIONS = [
|
|
85
|
+
{
|
|
86
|
+
key: "isoCode",
|
|
87
|
+
label: "Currency"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
key: "precision",
|
|
91
|
+
label: "Precision"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
key: "min",
|
|
95
|
+
label: "Min"
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
key: "max",
|
|
99
|
+
label: "Max"
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
key: "format",
|
|
103
|
+
label: "Format"
|
|
104
|
+
}
|
|
105
|
+
];
|
|
106
|
+
function detail(field, datasource, context) {
|
|
107
|
+
const lines = [`- ID: ${field.id}`, `- Type: ${fieldTypeLiteral(field).slice(1, -1)}`];
|
|
108
|
+
if (field.id === datasource.primaryFieldId) lines.push("- Primary field");
|
|
109
|
+
const chain = underlyingType(field);
|
|
110
|
+
if (chain.includes("->")) lines.push(`- Resolves: ${chain}`);
|
|
111
|
+
lines.push(...optionLines(field));
|
|
112
|
+
lines.push(...choiceLines(field));
|
|
113
|
+
lines.push(...linkLine(field, context));
|
|
114
|
+
const applied = flags(field);
|
|
115
|
+
if (applied.length > 0) lines.push(`- Flags: ${applied.join(", ")}`);
|
|
116
|
+
if (field.defaultValue !== null && field.defaultValue !== void 0) lines.push(`- Default: ${code(JSON.stringify(field.defaultValue))}`);
|
|
117
|
+
lines.push(...formulaLines(field));
|
|
118
|
+
lines.push(`- Example: ${code(readValueExample(field, context.datasources))}`);
|
|
119
|
+
return lines;
|
|
120
|
+
}
|
|
121
|
+
function flags(field) {
|
|
122
|
+
const applied = [];
|
|
123
|
+
if (field.required) applied.push("required");
|
|
124
|
+
if (!isWritable(field)) applied.push("readonly");
|
|
125
|
+
if (field.createOnly) applied.push("createOnly");
|
|
126
|
+
if (field.aiOnly === true) applied.push("aiOnly");
|
|
127
|
+
return applied;
|
|
128
|
+
}
|
|
129
|
+
function optionLines(field) {
|
|
130
|
+
const { options } = resolvedShape(field);
|
|
131
|
+
return DETAILED_OPTIONS.flatMap(({ key, label }) => {
|
|
132
|
+
const value = option(options, key);
|
|
133
|
+
if (value === null || value === void 0) return [];
|
|
134
|
+
assert(typeof value !== "object", `Option ${key} on field ${field.id} is not a scalar`);
|
|
135
|
+
return [`- ${label}: ${String(value)}`];
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function linkLine(field, context) {
|
|
139
|
+
const linkedId = option(field.options, "linkedTableId");
|
|
140
|
+
if (typeof linkedId !== "string") return [];
|
|
141
|
+
const linked = context.datasources.get(linkedId);
|
|
142
|
+
if (linked === void 0) return [`- Links to: ${linkedId}, not registered`];
|
|
143
|
+
return [`- Links to: ${docLink(linked, context)} (${linked.id})`];
|
|
144
|
+
}
|
|
145
|
+
function choiceLines(field) {
|
|
146
|
+
const choices = choiceEntries(resolvedShape(field).options);
|
|
147
|
+
if (choices === null) return [];
|
|
148
|
+
return ["- Choices:", ...choices.map(({ id, label }) => ` - ${id} ${displayName(label)}`)];
|
|
149
|
+
}
|
|
150
|
+
function formulaLines(field) {
|
|
151
|
+
const formula = option(field.options, "formula");
|
|
152
|
+
if (typeof formula !== "string" || formula.includes("```")) return [];
|
|
153
|
+
return [
|
|
154
|
+
"- Formula:",
|
|
155
|
+
" ```",
|
|
156
|
+
...formula.split("\n").map((line) => ` ${line}`),
|
|
157
|
+
" ```"
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
function exampleFieldKeys(datasource) {
|
|
161
|
+
const keys = /* @__PURE__ */ new Map();
|
|
162
|
+
const used = /* @__PURE__ */ new Set();
|
|
163
|
+
for (const field of datasource.fields) {
|
|
164
|
+
const base = camelCase(field.name) || camelCase(field.id) || "field";
|
|
165
|
+
let key = base;
|
|
166
|
+
let suffix = 1;
|
|
167
|
+
while (used.has(key)) {
|
|
168
|
+
suffix += 1;
|
|
169
|
+
key = `${base}${suffix}`;
|
|
170
|
+
}
|
|
171
|
+
used.add(key);
|
|
172
|
+
keys.set(field.id, key);
|
|
173
|
+
}
|
|
174
|
+
return keys;
|
|
175
|
+
}
|
|
176
|
+
function renderExamples(datasource, datasources) {
|
|
177
|
+
const keys = exampleFieldKeys(datasource);
|
|
178
|
+
const keyOf = (field) => requireDefined(keys.get(field.id), `No example key for field ${field.id} on datasource ${datasource.id}`);
|
|
179
|
+
const queryId = camelCase(datasource.name) || camelCase(datasource.id);
|
|
180
|
+
assert(queryId !== "", `Datasource ${datasource.id} has no usable name`);
|
|
181
|
+
const name = pascalCase(datasource.name) || pascalCase(datasource.id);
|
|
182
|
+
const sampled = exampleFields(datasource);
|
|
183
|
+
const read = [
|
|
184
|
+
"## Read shape",
|
|
185
|
+
"",
|
|
186
|
+
"```ts",
|
|
187
|
+
"import { createServerFn } from '@tanstack/react-start'",
|
|
188
|
+
"import { defineQuery } from '@softr/sdk'",
|
|
189
|
+
"import { ds } from '@softr/sdk/server'",
|
|
190
|
+
"",
|
|
191
|
+
`const ${queryId} = defineQuery({`,
|
|
192
|
+
` id: ${quoted(queryId)},`,
|
|
193
|
+
` datasourceId: ${quoted(datasource.id)},`,
|
|
194
|
+
" fields: {",
|
|
195
|
+
...sampled.map((field) => ` ${keyOf(field)}: ${quoted(field.id)}, // ${fieldTypeLiteral(field).slice(1, -1)}`),
|
|
196
|
+
" },",
|
|
197
|
+
" permissions: { userGroup: 'ALL_USERS' },",
|
|
198
|
+
"})",
|
|
199
|
+
"",
|
|
200
|
+
`const list${name} = createServerFn().handler(async () => {`,
|
|
201
|
+
` return await ds.list(${queryId})`,
|
|
202
|
+
"})",
|
|
203
|
+
"```",
|
|
204
|
+
"",
|
|
205
|
+
"```ts",
|
|
206
|
+
"const fields = {",
|
|
207
|
+
...sampled.map((field) => ` ${keyOf(field)}: ${readValueExample(field, datasources)},`),
|
|
208
|
+
"}",
|
|
209
|
+
"```"
|
|
210
|
+
];
|
|
211
|
+
const writable = sampled.filter(isWritableExample);
|
|
212
|
+
if (writable.length === 0) return [...read, ""];
|
|
213
|
+
const mutationId = `create${name}`;
|
|
214
|
+
const write = [
|
|
215
|
+
"## Write shape",
|
|
216
|
+
"",
|
|
217
|
+
"```ts",
|
|
218
|
+
"import { createServerFn } from '@tanstack/react-start'",
|
|
219
|
+
"import { defineMutation } from '@softr/sdk'",
|
|
220
|
+
"import { ds } from '@softr/sdk/server'",
|
|
221
|
+
"",
|
|
222
|
+
`const ${mutationId} = defineMutation({`,
|
|
223
|
+
` id: ${quoted(mutationId)},`,
|
|
224
|
+
` datasourceId: ${quoted(datasource.id)},`,
|
|
225
|
+
" operation: 'create',",
|
|
226
|
+
" fields: {",
|
|
227
|
+
...writable.map((field) => ` ${keyOf(field)}: ${quoted(field.id)},`),
|
|
228
|
+
" },",
|
|
229
|
+
" permissions: { userGroup: 'LOGGED_IN_USERS' },",
|
|
230
|
+
"})",
|
|
231
|
+
"",
|
|
232
|
+
`const add${name} = createServerFn({ method: 'POST' }).handler(async () =>`,
|
|
233
|
+
` ds.create(${mutationId}, {`,
|
|
234
|
+
" data: {",
|
|
235
|
+
...writable.map((field) => ` ${keyOf(field)}: ${writeValueExample(field)},`),
|
|
236
|
+
" },",
|
|
237
|
+
" }),",
|
|
238
|
+
")",
|
|
239
|
+
"```",
|
|
240
|
+
""
|
|
241
|
+
];
|
|
242
|
+
return [
|
|
243
|
+
...read,
|
|
244
|
+
"",
|
|
245
|
+
...write
|
|
246
|
+
];
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* The first field of every type the datasource has, because two fields of one type show one
|
|
250
|
+
* shape. `SELECT` and `SELECT[]` are two types, so both appear, and `orderedFields` puts the
|
|
251
|
+
* primary field first, so it is always the first of its own type.
|
|
252
|
+
*/
|
|
253
|
+
function exampleFields(datasource) {
|
|
254
|
+
const types = /* @__PURE__ */ new Set();
|
|
255
|
+
return orderedFields(datasource).filter((field) => {
|
|
256
|
+
const type = fieldTypeLiteral(field);
|
|
257
|
+
if (types.has(type)) return false;
|
|
258
|
+
types.add(type);
|
|
259
|
+
return true;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
function isWritableExample(field) {
|
|
263
|
+
return isWritable(field) && field.aiOnly !== true;
|
|
264
|
+
}
|
|
265
|
+
function readValueExample(field, datasources) {
|
|
266
|
+
const choice = firstChoice(field);
|
|
267
|
+
if (choice !== null) return exampleLiteral(field, `{ id: ${quoted(choice.id)}, label: ${quoted(choice.label)} }`);
|
|
268
|
+
return exampleLiteral(field, readExample(field, datasources));
|
|
269
|
+
}
|
|
270
|
+
function writeValueExample(field) {
|
|
271
|
+
const choice = firstChoice(field);
|
|
272
|
+
if (choice !== null) return exampleLiteral(field, `{ id: ${quoted(choice.id)} }`);
|
|
273
|
+
return exampleLiteral(field, scalarExample(field));
|
|
274
|
+
}
|
|
275
|
+
function datasourceLabel(datasource) {
|
|
276
|
+
return displayName(datasource.name) || datasource.id;
|
|
277
|
+
}
|
|
278
|
+
/** A wider delimiter, because a value carrying a label can carry a backtick of its own. */
|
|
279
|
+
function code(value) {
|
|
280
|
+
return value.includes("`") ? `\`\` ${value} \`\`` : `\`${value}\``;
|
|
281
|
+
}
|
|
282
|
+
/** Names and labels arrive verbatim from the API, where a line break forges a row. */
|
|
283
|
+
function displayName(name) {
|
|
284
|
+
return name.replace(/\s+/g, " ").trim();
|
|
285
|
+
}
|
|
286
|
+
function quoted(value) {
|
|
287
|
+
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r")}'`;
|
|
288
|
+
}
|
|
289
|
+
function exampleLiteral(field, scalar) {
|
|
290
|
+
if (field.allowMultipleEntries) return scalar === null ? "[]" : `[${scalar}]`;
|
|
291
|
+
return scalar ?? "null";
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* A choice id the datasource declares, which is what the generated read type accepts. Read from
|
|
295
|
+
* the resolved shape, because the read type resolves a computed field the same way.
|
|
296
|
+
*/
|
|
297
|
+
function firstChoice(field) {
|
|
298
|
+
const shape = resolvedShape(field);
|
|
299
|
+
if (shape.type !== "SELECT") return null;
|
|
300
|
+
const choices = choiceEntries(shape.options);
|
|
301
|
+
return choices === null ? null : choices[0];
|
|
302
|
+
}
|
|
303
|
+
function camelCase(value) {
|
|
304
|
+
const parts = words(value);
|
|
305
|
+
if (parts.length === 0) return "";
|
|
306
|
+
const joined = parts.map((word, index) => index === 0 ? word[0].toLowerCase() + word.slice(1) : word[0].toUpperCase() + word.slice(1)).join("");
|
|
307
|
+
return /^[0-9]/.test(joined) ? `f${joined}` : joined;
|
|
308
|
+
}
|
|
309
|
+
function pascalCase(value) {
|
|
310
|
+
const joined = words(value).map((word) => word[0].toUpperCase() + word.slice(1)).join("");
|
|
311
|
+
return /^[0-9]/.test(joined) ? `F${joined}` : joined;
|
|
312
|
+
}
|
|
313
|
+
function words(value) {
|
|
314
|
+
return value.split(/[^A-Za-z0-9]+/).filter((word) => word.length > 0);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
//#endregion
|
|
318
|
+
export { renderDatasourceDoc, renderDatasourceIndex };
|
|
@@ -299,7 +299,7 @@ function fieldDoc(field, datasource, context, side) {
|
|
|
299
299
|
const lines = [`Name: ${field.name}`, `Underlying type: ${underlyingType(field)}`];
|
|
300
300
|
const tags = [];
|
|
301
301
|
if (field.defaultValue !== null && field.defaultValue !== void 0) tags.push(`@default ${JSON.stringify(field.defaultValue)}`);
|
|
302
|
-
const scalar = read ? readExample(field) : scalarExample(field);
|
|
302
|
+
const scalar = read ? readExample(field, context.datasources) : scalarExample(field);
|
|
303
303
|
if (scalar !== null) tags.push(`@example ${field.allowMultipleEntries ? `[${scalar}]` : scalar}`);
|
|
304
304
|
tags.push(...shapeTags(field));
|
|
305
305
|
if (field.id === datasource.primaryFieldId) tags.push("@primaryField");
|
|
@@ -373,21 +373,38 @@ function formulaTag(field) {
|
|
|
373
373
|
/** Stands in for any id in an example. The real ones are in the member type below it. */
|
|
374
374
|
const BLANK_ID = "00000000-0000-0000-0000-000000000000";
|
|
375
375
|
/** The read counterpart of {@link scalarExample}, wherever the API returns a different shape. */
|
|
376
|
-
function readExample(shape) {
|
|
376
|
+
function readExample(shape, datasources) {
|
|
377
377
|
switch (shape.type) {
|
|
378
378
|
case "SELECT": {
|
|
379
379
|
const choices = choiceEntries(shape.options);
|
|
380
380
|
return `{ id: '${BLANK_ID}', label: '${choices === null ? "text" : choices[0].label}' }`;
|
|
381
381
|
}
|
|
382
|
-
case "LINKED_RECORD": return `{ id: '${BLANK_ID}', label:
|
|
382
|
+
case "LINKED_RECORD": return `{ id: '${BLANK_ID}', label: ${linkedLabelExample(shape.options, datasources)} }`;
|
|
383
383
|
case "ATTACHMENT": return `{ id: '${BLANK_ID}', filename: 'file.pdf', type: 'application/pdf', url: 'https://example.com/file.pdf', expiresAt: '2026-03-14T09:30:00.000Z' }`;
|
|
384
384
|
case "LOCATION": return "'text'";
|
|
385
385
|
case "FORMULA":
|
|
386
386
|
case "LOOKUP":
|
|
387
|
-
case "ROLLUP": return readExample(returnedShape(shape));
|
|
387
|
+
case "ROLLUP": return readExample(returnedShape(shape), datasources);
|
|
388
388
|
default: return scalarExample(shape);
|
|
389
389
|
}
|
|
390
390
|
}
|
|
391
|
+
/**
|
|
392
|
+
* The label a read carries, resolved the way {@link linkedRecord} resolves its type: the linked
|
|
393
|
+
* table's primary field. `null` wherever that resolution fails, which says nothing about a table
|
|
394
|
+
* this app cannot see and inhabits every `LinkedRecord<T>` label.
|
|
395
|
+
*/
|
|
396
|
+
function linkedLabelExample(options, datasources) {
|
|
397
|
+
const linkedTableId = option(options, "linkedTableId");
|
|
398
|
+
if (typeof linkedTableId !== "string") return "null";
|
|
399
|
+
const linked = datasources.get(linkedTableId);
|
|
400
|
+
const primary = linked?.fields.find((field) => field.id === linked.primaryFieldId);
|
|
401
|
+
if (linked === void 0 || primary === void 0) return "null";
|
|
402
|
+
const rest = new Map(datasources);
|
|
403
|
+
rest.delete(linked.id);
|
|
404
|
+
const scalar = readExample(primary, rest);
|
|
405
|
+
if (scalar === null) return "null";
|
|
406
|
+
return primary.allowMultipleEntries ? `[${scalar}]` : scalar;
|
|
407
|
+
}
|
|
391
408
|
/** A literal showing the shape a write takes, or `null` where the shape is not settled. */
|
|
392
409
|
function scalarExample(shape) {
|
|
393
410
|
switch (shape.type) {
|
|
@@ -468,4 +485,4 @@ function indent(block) {
|
|
|
468
485
|
}
|
|
469
486
|
|
|
470
487
|
//#endregion
|
|
471
|
-
export { renderDatasourceTypes };
|
|
488
|
+
export { choiceEntries, fieldTypeLiteral, isWritable, option, readExample, renderDatasourceTypes, resolvedShape, scalarExample, underlyingType };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { panic } from "../assertions.js";
|
|
2
|
+
|
|
3
|
+
//#region src/codegen/doc-file-names.ts
|
|
4
|
+
function docFileNames(datasources) {
|
|
5
|
+
const bases = /* @__PURE__ */ new Map();
|
|
6
|
+
const seen = /* @__PURE__ */ new Map();
|
|
7
|
+
for (const datasource of datasources) {
|
|
8
|
+
const base = slug(datasource.name) || slug(datasource.id);
|
|
9
|
+
if (base === "") panic(`Datasource ${datasource.id} has no usable name`);
|
|
10
|
+
bases.set(datasource.id, base);
|
|
11
|
+
const key = base.toLowerCase();
|
|
12
|
+
seen.set(key, (seen.get(key) ?? 0) + 1);
|
|
13
|
+
}
|
|
14
|
+
const names = /* @__PURE__ */ new Map();
|
|
15
|
+
/** The index the docs route the agent through, which a datasource must not overwrite. */
|
|
16
|
+
const taken = new Set(["readme"]);
|
|
17
|
+
for (const [id, base] of bases) {
|
|
18
|
+
const candidate = seen.get(base.toLowerCase()) === 1 ? base : `${base}-${id}`;
|
|
19
|
+
let name = candidate;
|
|
20
|
+
for (let attempt = 2; taken.has(name.toLowerCase()); attempt++) name = `${candidate}-${attempt}`;
|
|
21
|
+
taken.add(name.toLowerCase());
|
|
22
|
+
names.set(id, `${name}.md`);
|
|
23
|
+
}
|
|
24
|
+
return names;
|
|
25
|
+
}
|
|
26
|
+
function slug(value) {
|
|
27
|
+
return value.split(/[^A-Za-z0-9]+/).filter((word) => word.length > 0).join("-");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
//#endregion
|
|
31
|
+
export { docFileNames };
|
|
@@ -32,8 +32,11 @@ function header(sdkVersion) {
|
|
|
32
32
|
return `/**
|
|
33
33
|
* THIS FILE IS AUTO GENERATED, DO NOT EDIT THIS MANUALLY
|
|
34
34
|
*
|
|
35
|
+
* This file exists for type checking. See docs/datasources/ for field reference
|
|
36
|
+
* and guide how to use it.
|
|
37
|
+
*
|
|
35
38
|
* Generated by @softr/sdk@${sdkVersion}
|
|
36
|
-
* This file is regenerated automatically
|
|
39
|
+
* This file is regenerated automatically when a change is made to the datasource
|
|
37
40
|
*/
|
|
38
41
|
|
|
39
42
|
export {};`;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { requireDefined } from "../assertions.js";
|
|
2
|
+
import { renderDatasourceDoc, renderDatasourceIndex } from "./datasource-docs.js";
|
|
3
|
+
import { docFileNames } from "./doc-file-names.js";
|
|
4
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
//#region src/codegen/write-datasource-docs.ts
|
|
8
|
+
/**
|
|
9
|
+
* Writes the index and one file per datasource, and touches nothing else in
|
|
10
|
+
* `docsDir`: the caller resolves it against `process.cwd()`, which can be any
|
|
11
|
+
* directory a developer ran the bin from.
|
|
12
|
+
*/
|
|
13
|
+
function writeDatasourceDocs(datasources, docsDir) {
|
|
14
|
+
const context = {
|
|
15
|
+
datasources: new Map(datasources.map((datasource) => [datasource.id, datasource])),
|
|
16
|
+
fileNames: docFileNames(datasources)
|
|
17
|
+
};
|
|
18
|
+
mkdirSync(docsDir, { recursive: true });
|
|
19
|
+
writeFileSync(path.join(docsDir, "README.md"), renderDatasourceIndex(datasources, context), "utf8");
|
|
20
|
+
for (const datasource of datasources) writeFileSync(path.join(docsDir, requireDefined(context.fileNames.get(datasource.id))), renderDatasourceDoc(datasource, context), "utf8");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
export { writeDatasourceDocs };
|
package/dist/package.js
CHANGED
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@ description: >-
|
|
|
16
16
|
metadata:
|
|
17
17
|
type: sub-skill
|
|
18
18
|
library: softr-sdk
|
|
19
|
-
library_version: 0.0.
|
|
19
|
+
library_version: 0.0.43
|
|
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.
|
|
15
|
+
library_version: 0.0.43
|
|
16
16
|
---
|
|
17
17
|
|
|
18
18
|
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
|
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
13
|
+
library_version: 0.0.43
|
|
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.
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: file-uploads
|
|
3
3
|
description: >-
|
|
4
|
-
Upload files from a form or edit view: useFileUpload(action) from @softr/sdk
|
|
5
|
-
everything — XHR upload with real progress, per-upload cancel, and
|
|
6
|
-
form submission. No route registration needed; the SDK
|
|
7
|
-
For server-side uploads or plain forms, use
|
|
8
|
-
For edit forms pass
|
|
4
|
+
Upload files from a form or edit view: useFileUpload(action) from @softr/sdk
|
|
5
|
+
does everything — XHR upload with real progress, per-upload cancel, and
|
|
6
|
+
isUploading to block form submission. No route registration needed; the SDK
|
|
7
|
+
ships its own server function. For server-side uploads or plain forms, use
|
|
8
|
+
upload(actionId, file) from @softr/sdk/server. For edit forms pass
|
|
9
|
+
initialAttachments to pre-load existing attachments.
|
|
9
10
|
metadata:
|
|
10
11
|
type: sub-skill
|
|
11
12
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
13
|
+
library_version: 0.0.43
|
|
13
14
|
---
|
|
14
15
|
|
|
15
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.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.43
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `integration` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineIntegration` and every type come from `@softr/sdk` and can be imported anywhere. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
|
|
@@ -148,6 +148,10 @@ Match the `integrationType` from the attached integrations section to find the b
|
|
|
148
148
|
| XERO | `https://api.xero.com` |
|
|
149
149
|
| ZOOM | `https://api.zoom.us` |
|
|
150
150
|
|
|
151
|
+
## When the type is in the table but nothing is attached
|
|
152
|
+
|
|
153
|
+
The service is not out of reach, it is not connected yet. Load `connect-a-service` and ask the user for it: the answer carries the `integrationId` this skill needs.
|
|
154
|
+
|
|
151
155
|
## When the service is not a Softr integration
|
|
152
156
|
|
|
153
157
|
If the API the user needs is not in the table above, call it directly from a server function using the native `fetch`. The user supplies their own credentials and the call lives entirely in the server function handler.
|
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
metadata:
|
|
12
12
|
type: sub-skill
|
|
13
13
|
library: softr-sdk
|
|
14
|
-
library_version: 0.0.
|
|
14
|
+
library_version: 0.0.43
|
|
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.
|
|
@@ -13,7 +13,7 @@ description: >-
|
|
|
13
13
|
metadata:
|
|
14
14
|
type: sub-skill
|
|
15
15
|
library: softr-sdk
|
|
16
|
-
library_version: 0.0.
|
|
16
|
+
library_version: 0.0.43
|
|
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. `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.
|
|
@@ -183,8 +183,7 @@ A read the datasource turns down throws rather than coming back empty, and it th
|
|
|
183
183
|
|
|
184
184
|
## Typing
|
|
185
185
|
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
Queries you write are type safe and a type error is shown when there's a mistake. For an in depth reference on what fields every datasource has check own project's `docs/datasources/` directory.
|
|
188
187
|
|
|
189
188
|
## References
|
|
190
189
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Softr field types and what their values look like
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Your own tables are recapped in `docs/datasources/`, one generated file per registered datasource: every field with its name, id, type and constraints, choice ids included. This reference is the general mapping behind those files, for reading a record's values without opening them.
|
|
4
4
|
|
|
5
5
|
Three rules cover nullability:
|
|
6
6
|
|
|
@@ -39,7 +39,7 @@ ATTACHMENT: {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
// Computed fields read as whatever they return
|
|
42
|
-
FORMULA, LOOKUP, ROLLUP: the returned field's shape, resolved in
|
|
42
|
+
FORMULA, LOOKUP, ROLLUP: the returned field's shape, resolved in docs/datasources/
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
Every scalar above is `| null` on the record unless the field allows multiple entries, in which case the value is an `Array<...>` of the scalar.
|
|
@@ -23,7 +23,7 @@ A `where` callback returns exactly one value. For more than one condition, wrap
|
|
|
23
23
|
|
|
24
24
|
## Operators by field type
|
|
25
25
|
|
|
26
|
-
The handle for each field carries the operators its declared type supports
|
|
26
|
+
The handle for each field carries the operators its declared type supports.
|
|
27
27
|
|
|
28
28
|
### Text fields
|
|
29
29
|
|
|
@@ -6,13 +6,13 @@ description: >-
|
|
|
6
6
|
four ways to enforce a rule (guard, assert, isAuthorized, check). Covers
|
|
7
7
|
per-reason overrides, the redirect and throw strategies, rendering an access
|
|
8
8
|
error with isAccessError, isUnauthenticatedError, isForbiddenError and
|
|
9
|
-
isAlreadyAuthenticatedError, guarding a layout, and the lint rule that
|
|
10
|
-
declaration alongside the rules that reject a duplicate, computed
|
|
11
|
-
root-level one.
|
|
9
|
+
isAlreadyAuthenticatedError, guarding a layout, and the lint rule that
|
|
10
|
+
requires a declaration alongside the rules that reject a duplicate, computed
|
|
11
|
+
or root-level one.
|
|
12
12
|
metadata:
|
|
13
13
|
type: sub-skill
|
|
14
14
|
library: softr-sdk
|
|
15
|
-
library_version: 0.0.
|
|
15
|
+
library_version: 0.0.43
|
|
16
16
|
---
|
|
17
17
|
|
|
18
18
|
**Boundary.** `defineVisibility`, the error predicates and every type come from `@softr/sdk` and work anywhere; `ds` and `auth` live on `@softr/sdk/server` and run only inside a `createServerFn` handler. The app is already wired — `src/start.ts` registers `softrAuthMiddleware`, `src/routes/__root.tsx` publishes the visitor as route context — never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.43
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `workflow` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineWorkflow` and every type come from `@softr/sdk` and can be imported anywhere. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
|
|
@@ -28,7 +28,7 @@ import { workflow } from '@softr/sdk/server'
|
|
|
28
28
|
|
|
29
29
|
## Declare the workflow action
|
|
30
30
|
|
|
31
|
-
Every trigger needs a workflow action: which workflow, which trigger step, who may run it, and any data that is always sent.
|
|
31
|
+
Every trigger needs a workflow action: which workflow, which trigger step, who may run it, and any data that is always sent. The workflow must have a **Run custom workflow** trigger — no other trigger type can be fired from app code.
|
|
32
32
|
|
|
33
33
|
```ts
|
|
34
34
|
const sendWelcomeEmail = defineWorkflow({
|
|
@@ -45,7 +45,7 @@ const sendWelcomeEmail = defineWorkflow({
|
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
- `id` is a name you choose, unique across the app.
|
|
48
|
-
- `workflowId` and `triggerId` are opaque UUIDs
|
|
48
|
+
- `workflowId` and `triggerId` are opaque UUIDs identifying the workflow and its **Run custom workflow** trigger. `list_workflows` returns both. Pass them as inline string literals — never derive them at runtime.
|
|
49
49
|
- `permissions` declares who may run the trigger. Uses the same format as queries and mutations.
|
|
50
50
|
- `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).
|
|
51
51
|
|
|
@@ -107,6 +107,8 @@ const submit = useServerFn(run)
|
|
|
107
107
|
|
|
108
108
|
**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.
|
|
109
109
|
|
|
110
|
+
**Pointing at a workflow with no Run custom workflow trigger.** A workflow that runs on a schedule, a table event or an integration event has no trigger the app can call, and `workflow.trigger` fails at request time. Confirm the workflow has one before writing the `defineWorkflow`. If it does not, say which trigger it has instead — never that the workflow is missing — and name the ones that do have a Run custom workflow trigger.
|
|
111
|
+
|
|
110
112
|
**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.
|
|
111
113
|
|
|
112
114
|
**Not awaiting the result.** `workflow.trigger` is async. Fire-and-forget loses the result and swallows any error.
|