@softr/sdk 0.0.25 → 0.0.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/action.d.ts +195 -0
- package/dist/action.js +21 -0
- package/dist/auth/isomorphic.d.ts +1 -2
- package/dist/auth/permissions.d.ts +7 -2
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +639 -0
- package/dist/codegen/datasource-types.js +471 -0
- package/dist/codegen/softr-env.js +103 -0
- package/dist/codegen/user-groups.js +11 -0
- package/dist/core/integration.js +1 -1
- package/dist/datasource/chart.d.ts +196 -0
- package/dist/datasource/chart.js +289 -0
- package/dist/datasource/datasource.d.ts +16 -0
- package/dist/datasource/datasource.js +17 -0
- package/dist/datasource/fields.d.ts +122 -0
- package/dist/datasource/fields.js +82 -0
- package/dist/datasource/filter.d.ts +30 -0
- package/dist/datasource/filter.js +146 -0
- package/dist/datasource/record.d.ts +138 -0
- package/dist/datasource/record.js +258 -0
- package/dist/index.d.ts +3 -5
- package/dist/index.js +2 -2
- package/dist/package.js +5 -0
- package/dist/routes/visibility.d.ts +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/package.json +2 -1
- package/skills/softr-sdk/SKILL.md +6 -2
- package/skills/softr-sdk/auth/SKILL.md +1 -1
- package/skills/softr-sdk/current-user/SKILL.md +1 -1
- package/skills/softr-sdk/data-mutations/SKILL.md +1 -1
- package/skills/softr-sdk/integrations/SKILL.md +140 -0
- package/skills/softr-sdk/reading-records/SKILL.md +1 -1
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +1 -1
- package/dist/core/datasource.d.ts +0 -31
- package/dist/core/datasource.js +0 -226
- package/dist/core/query.d.ts +0 -354
- package/dist/core/query.js +0 -528
- package/dist/core/utils.js +0 -14
- package/dist/types.d.ts +0 -128
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { panic } from "../assertions.js";
|
|
2
|
+
|
|
3
|
+
//#region src/codegen/datasource-types.ts
|
|
4
|
+
/**
|
|
5
|
+
* `LOCATION` is in the field type enum and nowhere else. It has no subtype on the record model
|
|
6
|
+
* and no conversion branch, so the API drops a write to one without an error.
|
|
7
|
+
*/
|
|
8
|
+
const UNWRITABLE_TYPES = new Set(["LOCATION"]);
|
|
9
|
+
/**
|
|
10
|
+
* A `readonly` field is dropped from a write on every path, silently rather than with an
|
|
11
|
+
* error, so leaving one out of the write type costs nothing and says so at the call site.
|
|
12
|
+
*/
|
|
13
|
+
function isWritable(field) {
|
|
14
|
+
return !field.readonly && !UNWRITABLE_TYPES.has(field.type);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Renders a write type, a create, an update and a read type per datasource, then
|
|
18
|
+
* `SoftrDatasources`.
|
|
19
|
+
*
|
|
20
|
+
* The caller wraps it. Pass the whole project: a linked record resolves against the other
|
|
21
|
+
* datasources in `datasources`.
|
|
22
|
+
*
|
|
23
|
+
* Only the interface belongs inside `declare module "@softr/sdk"`. The per-datasource types go
|
|
24
|
+
* at the file's top level, because an augmentation merges an interface but collides on a type
|
|
25
|
+
* alias, and an alias declared there becomes an SDK export that a future SDK release would
|
|
26
|
+
* duplicate.
|
|
27
|
+
*
|
|
28
|
+
* The output references value aliases it does not define: `IsoDate`, `IsoDateTime`,
|
|
29
|
+
* `IsoTime`, `EmailAddress`, `PhoneNumber`, `HttpUrl`, `LinkedRecord`, `LinkedRecordInput`,
|
|
30
|
+
* `WorkspaceUser`, `Attachment`, `AttachmentInput`, `SelectChoice` and `SelectChoiceInput`. They are the same for every project, so they belong
|
|
31
|
+
* in the SDK, which does not export them yet. Until it does, the caller declares them at the
|
|
32
|
+
* top of the generated file. An augmentation body cannot pick them up from the module it
|
|
33
|
+
* augments: unqualified names there do not resolve against the augmented module's scope.
|
|
34
|
+
*/
|
|
35
|
+
function renderDatasourceTypes(datasources) {
|
|
36
|
+
const context = {
|
|
37
|
+
datasources: byId(datasources),
|
|
38
|
+
names: typeNames(datasources)
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
datasourceTypes: datasources.map((datasource) => renderDatasource(datasource, context)).join("\n\n"),
|
|
42
|
+
interfaces: renderRegistry(datasources, context)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function byId(datasources) {
|
|
46
|
+
return new Map(datasources.map((datasource) => [datasource.id, datasource]));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A TypeScript name per datasource, derived from its own name, with the id standing in wherever
|
|
50
|
+
* the name cannot: two datasources can share a name, and a name can be blank or hold nothing an
|
|
51
|
+
* identifier can keep. The id is unique by construction, so it settles both.
|
|
52
|
+
*/
|
|
53
|
+
function typeNames(datasources) {
|
|
54
|
+
const bases = /* @__PURE__ */ new Map();
|
|
55
|
+
const seen = /* @__PURE__ */ new Map();
|
|
56
|
+
for (const datasource of datasources) {
|
|
57
|
+
const base = pascalCase(datasource.name) || pascalCase(datasource.id);
|
|
58
|
+
if (base === "") panic(`Datasource ${datasource.id} has no usable name`);
|
|
59
|
+
bases.set(datasource.id, base);
|
|
60
|
+
seen.set(base, (seen.get(base) ?? 0) + 1);
|
|
61
|
+
}
|
|
62
|
+
const names = /* @__PURE__ */ new Map();
|
|
63
|
+
for (const [id, base] of bases) names.set(id, seen.get(base) === 1 ? base : `${base}_${pascalCase(id)}`);
|
|
64
|
+
return names;
|
|
65
|
+
}
|
|
66
|
+
/** Empty when the value holds nothing an identifier can keep. */
|
|
67
|
+
function pascalCase(value) {
|
|
68
|
+
const joined = value.split(/[^A-Za-z0-9]+/).filter((word) => word.length > 0).map((word) => word[0].toUpperCase() + word.slice(1)).join("");
|
|
69
|
+
return /^[0-9]/.test(joined) ? `DS${joined}` : joined;
|
|
70
|
+
}
|
|
71
|
+
function renderDatasource(datasource, context) {
|
|
72
|
+
const name = nameOf(datasource.id, context);
|
|
73
|
+
const write = datasource.fields.filter(isWritable).map((field) => indent(doc(fieldDoc(field, datasource, context, "write")) + "\n" + writeMember(field, context))).join("\n\n");
|
|
74
|
+
const record = datasource.fields.map((field) => indent(doc(fieldDoc(field, datasource, context, "read")) + "\n" + readMember(field, context))).join("\n\n");
|
|
75
|
+
return [
|
|
76
|
+
`type ${name}Write = {`,
|
|
77
|
+
write,
|
|
78
|
+
"};",
|
|
79
|
+
"",
|
|
80
|
+
`type ${name}Create = ${name}Write;`,
|
|
81
|
+
"",
|
|
82
|
+
`type ${name}Update = ${name}Write;`,
|
|
83
|
+
"",
|
|
84
|
+
`type ${name}Read = {`,
|
|
85
|
+
record,
|
|
86
|
+
"};"
|
|
87
|
+
].join("\n");
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A write is optional and nullable throughout. Optional because the API requires no field on a
|
|
91
|
+
* create and merges an update key by key; nullable because `null` is how an update clears a
|
|
92
|
+
* field. On a create `null` reaches for the field's default instead.
|
|
93
|
+
*/
|
|
94
|
+
function writeMember(field, context) {
|
|
95
|
+
return `${key(field.id)}?:${spaced(orNull(multiple(field, writeValue(field, context))))};`;
|
|
96
|
+
}
|
|
97
|
+
/** A union that broke onto its own lines already starts with a newline. */
|
|
98
|
+
function spaced(value) {
|
|
99
|
+
return value.startsWith("\n") ? value : ` ${value}`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* A read is present and nullable. Every field carries a value, `null` when it is empty, so
|
|
103
|
+
* there is nothing to make optional.
|
|
104
|
+
*
|
|
105
|
+
* The exceptions are the two the API fills in instead of emptying: a multiple field is an
|
|
106
|
+
* array, empty rather than null, and a `CHECKBOX` is a boolean, false rather than null.
|
|
107
|
+
*/
|
|
108
|
+
function readMember(field, context) {
|
|
109
|
+
const nullable = !field.allowMultipleEntries && field.type !== "CHECKBOX";
|
|
110
|
+
const value = multiple(field, readValue(field, context));
|
|
111
|
+
return `${key(field.id)}:${spaced(nullable ? orNull(value) : value)};`;
|
|
112
|
+
}
|
|
113
|
+
/** Keeps a union that already broke onto its own lines from growing a trailing member. */
|
|
114
|
+
function orNull(value) {
|
|
115
|
+
return value.startsWith("\n") ? `${value}\n | null` : `${value} | null`;
|
|
116
|
+
}
|
|
117
|
+
function multiple(field, value) {
|
|
118
|
+
if (!field.allowMultipleEntries) return value;
|
|
119
|
+
return value.startsWith("\n") ? `Array<${value}\n>` : `Array<${value}>`;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The value a field accepts on a write.
|
|
123
|
+
*
|
|
124
|
+
* The API takes more than this. A bare string stands in for a `SELECT` or a `LINKED_RECORD`
|
|
125
|
+
* and becomes its own id, a numeric string parses as a number, and an array sent to a
|
|
126
|
+
* single-entry field keeps only its first element with no error. Each of those loses data
|
|
127
|
+
* quietly, so the type admits only the shape that cannot.
|
|
128
|
+
*/
|
|
129
|
+
function writeValue(shape, context) {
|
|
130
|
+
switch (shape.type) {
|
|
131
|
+
case "SINGLE_LINE_TEXT":
|
|
132
|
+
case "LONG_TEXT":
|
|
133
|
+
case "ADDRESS":
|
|
134
|
+
case "BUTTON":
|
|
135
|
+
case "RECORD_ID":
|
|
136
|
+
case "CREATED_BY":
|
|
137
|
+
case "UPDATED_BY": return "string";
|
|
138
|
+
case "URL": return "HttpUrl";
|
|
139
|
+
case "EMAIL": return "EmailAddress";
|
|
140
|
+
case "PHONE": return "PhoneNumber";
|
|
141
|
+
case "CHECKBOX": return "boolean";
|
|
142
|
+
case "NUMBER":
|
|
143
|
+
case "CURRENCY":
|
|
144
|
+
case "PERCENT":
|
|
145
|
+
case "PROGRESS":
|
|
146
|
+
case "DURATION":
|
|
147
|
+
case "RATING":
|
|
148
|
+
case "COUNT":
|
|
149
|
+
case "AUTONUMBER": return "number";
|
|
150
|
+
case "DATETIME": return datePrecision(shape.options) === "DATE" ? "IsoDate" : "IsoDateTime";
|
|
151
|
+
case "CREATED_AT":
|
|
152
|
+
case "UPDATED_AT": return "IsoDateTime";
|
|
153
|
+
case "TIME": return "IsoTime";
|
|
154
|
+
case "DATE_RANGE": return "{ from: IsoDate; to: IsoDate }";
|
|
155
|
+
case "SELECT": return selectInput(shape.options);
|
|
156
|
+
case "ATTACHMENT": return "AttachmentInput";
|
|
157
|
+
case "LINKED_RECORD": return "LinkedRecordInput";
|
|
158
|
+
case "USER": return "WorkspaceUser";
|
|
159
|
+
case "FORMULA":
|
|
160
|
+
case "LOOKUP":
|
|
161
|
+
case "ROLLUP": return writeValue(returnedShape(shape), context);
|
|
162
|
+
default: return panic(`${shape.type} has no write shape`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** A read differs from a write only where the API returns a different shape than it takes. */
|
|
166
|
+
function readValue(shape, context) {
|
|
167
|
+
switch (shape.type) {
|
|
168
|
+
case "ATTACHMENT": return "Attachment";
|
|
169
|
+
case "SELECT": return selectValue(shape.options);
|
|
170
|
+
case "LINKED_RECORD": return linkedRecord(shape.options, context);
|
|
171
|
+
case "LOCATION": return "string";
|
|
172
|
+
case "FORMULA":
|
|
173
|
+
case "LOOKUP":
|
|
174
|
+
case "ROLLUP": return readValue(returnedShape(shape), context);
|
|
175
|
+
default: return writeValue(shape, context);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* A computed field is typed by what it returns, which the metadata nests under `options`.
|
|
180
|
+
* The nesting is recursive on the wire, so a lookup through a lookup resolves.
|
|
181
|
+
*
|
|
182
|
+
* `FORMULA`, `LOOKUP` and `ROLLUP` all declare `returnedField` as required, so a missing one
|
|
183
|
+
* is malformed metadata. `options` is absent for a returned type that has none.
|
|
184
|
+
*
|
|
185
|
+
* Whether the value is an array follows `allowMultipleEntries` like every other type, so the
|
|
186
|
+
* shape resolved here is the scalar one.
|
|
187
|
+
*/
|
|
188
|
+
function returnedShape(shape) {
|
|
189
|
+
const returned = option(shape.options, "returnedField");
|
|
190
|
+
if (!isValueShape(returned)) return panic(`${shape.type} has no returnedField`);
|
|
191
|
+
return {
|
|
192
|
+
type: returned.type,
|
|
193
|
+
options: returned.options ?? null
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** A choice reads back with the label the field holds for it, so each member is one pair. */
|
|
197
|
+
function selectValue(options) {
|
|
198
|
+
const choices = choiceEntries(options);
|
|
199
|
+
if (choices === null) return "SelectChoice";
|
|
200
|
+
return union(choices.map(({ id, label }) => `{ id: ${quote(id)}; label: ${quote(label)} }`));
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* A write carries the id alone. The API resolves it against the choice list and writes that
|
|
204
|
+
* choice's own label, so a label sent alongside is dropped.
|
|
205
|
+
*
|
|
206
|
+
* The API also takes `{ label }` with no id, and a label matching nothing creates a choice.
|
|
207
|
+
* The union omits that: it would make every misspelling type-check, and the cost of one is a
|
|
208
|
+
* new choice in the field rather than an error.
|
|
209
|
+
*/
|
|
210
|
+
function selectInput(options) {
|
|
211
|
+
const choices = choiceEntries(options);
|
|
212
|
+
if (choices === null) return "SelectChoiceInput";
|
|
213
|
+
return union(choices.map(({ id }) => `{ id: ${quote(id)} }`));
|
|
214
|
+
}
|
|
215
|
+
function datePrecision(options) {
|
|
216
|
+
const precision = option(options, "precision");
|
|
217
|
+
return typeof precision === "string" ? precision : null;
|
|
218
|
+
}
|
|
219
|
+
function choiceEntries(options) {
|
|
220
|
+
const choices = option(options, "choices");
|
|
221
|
+
if (!Array.isArray(choices)) return null;
|
|
222
|
+
const entries = [];
|
|
223
|
+
for (const choice of choices) {
|
|
224
|
+
if (typeof choice !== "object" || choice === null) continue;
|
|
225
|
+
const { id, label } = choice;
|
|
226
|
+
if (typeof id === "string" && typeof label === "string") entries.push({
|
|
227
|
+
id,
|
|
228
|
+
label
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return entries.length === 0 ? null : entries;
|
|
232
|
+
}
|
|
233
|
+
/** Wraps a union onto its own lines once there is more than one member, so it stays legible. */
|
|
234
|
+
function union(members) {
|
|
235
|
+
if (members.length === 1) return members[0];
|
|
236
|
+
return "\n" + members.map((member) => ` | ${member}`).join("\n");
|
|
237
|
+
}
|
|
238
|
+
function linkedRecord(options, context) {
|
|
239
|
+
const linkedTableId = option(options, "linkedTableId");
|
|
240
|
+
if (typeof linkedTableId !== "string") return "LinkedRecord";
|
|
241
|
+
const linked = context.datasources.get(linkedTableId);
|
|
242
|
+
if (linked === void 0 || linked.primaryFieldId === "") return "LinkedRecord";
|
|
243
|
+
return `LinkedRecord<NonNullable<${`${nameOf(linked.id, context)}Read[${quote(linked.primaryFieldId)}]`}>>`;
|
|
244
|
+
}
|
|
245
|
+
function renderRegistry(datasources, context) {
|
|
246
|
+
return [
|
|
247
|
+
"interface SoftrDatasources {",
|
|
248
|
+
datasources.map((datasource) => {
|
|
249
|
+
const name = nameOf(datasource.id, context);
|
|
250
|
+
return indent([
|
|
251
|
+
`${key(datasource.id)}: {`,
|
|
252
|
+
` name: ${quote(datasource.name)};`,
|
|
253
|
+
` primaryField: ${quote(datasource.primaryFieldId)};`,
|
|
254
|
+
` read: ${name}Read;`,
|
|
255
|
+
` create: ${name}Create;`,
|
|
256
|
+
` update: ${name}Update;`,
|
|
257
|
+
` fieldTypes: {`,
|
|
258
|
+
...datasource.fields.map((field) => ` ${key(field.id)}: ${fieldTypeLiteral(field)};`),
|
|
259
|
+
` };`,
|
|
260
|
+
`};`
|
|
261
|
+
].join("\n"));
|
|
262
|
+
}).join("\n"),
|
|
263
|
+
"}"
|
|
264
|
+
].join("\n");
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* A field's declared type, which is what the SDK picks a filter chain from. The type name
|
|
268
|
+
* goes out verbatim, with one exception: a `DATETIME` at `DATE` precision goes out as
|
|
269
|
+
* `DATE`, because the two accept different chart buckets and the raw type name does not
|
|
270
|
+
* tell them apart. The chain each one gets is the SDK's policy, so changing it does not
|
|
271
|
+
* mean regenerating every app.
|
|
272
|
+
*
|
|
273
|
+
* `[]` is a suffix inside the literal rather than a TypeScript array, because the type name
|
|
274
|
+
* and the multiplicity are one fact about the column here, not a value shape.
|
|
275
|
+
*/
|
|
276
|
+
function fieldTypeLiteral(field) {
|
|
277
|
+
const shape = resolvedShape(field);
|
|
278
|
+
const type = shape.type === "DATETIME" && datePrecision(shape.options) === "DATE" ? "DATE" : shape.type;
|
|
279
|
+
return quote(field.allowMultipleEntries ? `${type}[]` : type);
|
|
280
|
+
}
|
|
281
|
+
/** A computed field is its returned shape, all the way down, exactly as {@link readValue} does. */
|
|
282
|
+
function resolvedShape(shape) {
|
|
283
|
+
switch (shape.type) {
|
|
284
|
+
case "FORMULA":
|
|
285
|
+
case "LOOKUP":
|
|
286
|
+
case "ROLLUP": return resolvedShape(returnedShape(shape));
|
|
287
|
+
default: return shape;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The block above a member: the field's own name and its DSv2 type, then whichever tags apply.
|
|
292
|
+
*
|
|
293
|
+
* The two sides differ only in the value they describe. A read builds `@example` against the
|
|
294
|
+
* read type, and carries `@readonly`, `@lookup` and `@formula`, none of which can reach a write
|
|
295
|
+
* type a readonly field is already absent from.
|
|
296
|
+
*/
|
|
297
|
+
function fieldDoc(field, datasource, context, side) {
|
|
298
|
+
const read = side === "read";
|
|
299
|
+
const lines = [`Name: ${field.name}`, `Underlying type: ${underlyingType(field)}`];
|
|
300
|
+
const tags = [];
|
|
301
|
+
if (field.defaultValue !== null && field.defaultValue !== void 0) tags.push(`@default ${JSON.stringify(field.defaultValue)}`);
|
|
302
|
+
const scalar = read ? readExample(field) : scalarExample(field);
|
|
303
|
+
if (scalar !== null) tags.push(`@example ${field.allowMultipleEntries ? `[${scalar}]` : scalar}`);
|
|
304
|
+
tags.push(...shapeTags(field));
|
|
305
|
+
if (field.id === datasource.primaryFieldId) tags.push("@primaryField");
|
|
306
|
+
if (read && field.readonly) tags.push("@readonly");
|
|
307
|
+
if (field.required) tags.push("@required");
|
|
308
|
+
if (field.createOnly) tags.push("@createOnly");
|
|
309
|
+
if (read) {
|
|
310
|
+
const lookup = lookupTag(field, datasource, context);
|
|
311
|
+
if (lookup !== null) tags.push(lookup);
|
|
312
|
+
tags.push(...formulaTag(field) ?? []);
|
|
313
|
+
}
|
|
314
|
+
return tags.length === 0 ? lines : [
|
|
315
|
+
...lines,
|
|
316
|
+
"",
|
|
317
|
+
...tags
|
|
318
|
+
];
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* The bounds a value has to respect, as opposed to the ones Studio only draws with. A
|
|
322
|
+
* `DATETIME` is excluded from `@precision` by the type of the option: it holds `"DATE"`.
|
|
323
|
+
*/
|
|
324
|
+
function shapeTags(field) {
|
|
325
|
+
const tags = [];
|
|
326
|
+
const maxLength = option(field.options, "maxLength");
|
|
327
|
+
if (typeof maxLength === "number") tags.push(`@maxLength ${maxLength}`);
|
|
328
|
+
const precision = option(field.options, "precision");
|
|
329
|
+
if (typeof precision === "number") tags.push(`@precision ${precision}`);
|
|
330
|
+
const isoCode = option(field.options, "isoCode");
|
|
331
|
+
if (typeof isoCode === "string") tags.push(`@currency ${isoCode}`);
|
|
332
|
+
return tags;
|
|
333
|
+
}
|
|
334
|
+
/** Where a lookup reads from, as the link on this datasource and the field at the far end. */
|
|
335
|
+
function lookupTag(field, datasource, context) {
|
|
336
|
+
if (field.type !== "LOOKUP") return null;
|
|
337
|
+
const linkId = option(field.options, "linkedRecordFieldId");
|
|
338
|
+
const targetId = option(field.options, "lookupFieldId");
|
|
339
|
+
const link = datasource.fields.find((candidate) => candidate.id === linkId);
|
|
340
|
+
if (link === void 0) return null;
|
|
341
|
+
const linkedTableId = option(link.options, "linkedTableId");
|
|
342
|
+
const target = (typeof linkedTableId === "string" ? context.datasources.get(linkedTableId) : void 0)?.fields.find((candidate) => candidate.id === targetId);
|
|
343
|
+
if (target === void 0) return null;
|
|
344
|
+
return `@lookup ${link.name} -> ${target.name}`;
|
|
345
|
+
}
|
|
346
|
+
/** The DSv2 type, with a computed field's wrapper spelled out down to what it resolves to. */
|
|
347
|
+
function underlyingType(field) {
|
|
348
|
+
const chain = typeChain(field);
|
|
349
|
+
return field.allowMultipleEntries ? `${chain}[]` : chain;
|
|
350
|
+
}
|
|
351
|
+
function typeChain(shape) {
|
|
352
|
+
switch (shape.type) {
|
|
353
|
+
case "FORMULA":
|
|
354
|
+
case "LOOKUP":
|
|
355
|
+
case "ROLLUP": return `${shape.type} -> ${typeChain(returnedShape(shape))}`;
|
|
356
|
+
default: return shape.type;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* A formula's expression, split across continuation lines under the tag.
|
|
361
|
+
*
|
|
362
|
+
* An expression carrying the comment terminator loses the tag rather than being rewritten: the
|
|
363
|
+
* point of the tag is to reproduce the expression exactly.
|
|
364
|
+
*/
|
|
365
|
+
function formulaTag(field) {
|
|
366
|
+
if (field.type !== "FORMULA") return null;
|
|
367
|
+
const formula = option(field.options, "formula");
|
|
368
|
+
if (typeof formula !== "string" || formula.includes("*/")) return null;
|
|
369
|
+
const pad = " ".repeat(9);
|
|
370
|
+
const [first, ...rest] = formula.split("\n");
|
|
371
|
+
return [`@formula ${first}`, ...rest.map((line) => line === "" ? "" : pad + line)];
|
|
372
|
+
}
|
|
373
|
+
/** Stands in for any id in an example. The real ones are in the member type below it. */
|
|
374
|
+
const BLANK_ID = "00000000-0000-0000-0000-000000000000";
|
|
375
|
+
/** The read counterpart of {@link scalarExample}, wherever the API returns a different shape. */
|
|
376
|
+
function readExample(shape) {
|
|
377
|
+
switch (shape.type) {
|
|
378
|
+
case "SELECT": {
|
|
379
|
+
const choices = choiceEntries(shape.options);
|
|
380
|
+
return `{ id: '${BLANK_ID}', label: '${choices === null ? "text" : choices[0].label}' }`;
|
|
381
|
+
}
|
|
382
|
+
case "LINKED_RECORD": return `{ id: '${BLANK_ID}', label: 'text' }`;
|
|
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
|
+
case "LOCATION": return "'text'";
|
|
385
|
+
case "FORMULA":
|
|
386
|
+
case "LOOKUP":
|
|
387
|
+
case "ROLLUP": return readExample(returnedShape(shape));
|
|
388
|
+
default: return scalarExample(shape);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/** A literal showing the shape a write takes, or `null` where the shape is not settled. */
|
|
392
|
+
function scalarExample(shape) {
|
|
393
|
+
switch (shape.type) {
|
|
394
|
+
case "SINGLE_LINE_TEXT":
|
|
395
|
+
case "LONG_TEXT":
|
|
396
|
+
case "ADDRESS":
|
|
397
|
+
case "BUTTON":
|
|
398
|
+
case "RECORD_ID": return "'text'";
|
|
399
|
+
case "EMAIL": return "'name@example.com'";
|
|
400
|
+
case "PHONE": return "'+15551234567'";
|
|
401
|
+
case "URL": return "'https://example.com'";
|
|
402
|
+
case "NUMBER":
|
|
403
|
+
case "CURRENCY":
|
|
404
|
+
case "PERCENT":
|
|
405
|
+
case "PROGRESS":
|
|
406
|
+
case "COUNT":
|
|
407
|
+
case "AUTONUMBER": return String(numberExample(shape.options));
|
|
408
|
+
case "DURATION": return "3600";
|
|
409
|
+
case "RATING": {
|
|
410
|
+
const max = option(shape.options, "max");
|
|
411
|
+
return String(typeof max === "number" ? max : 5);
|
|
412
|
+
}
|
|
413
|
+
case "CHECKBOX": return "true";
|
|
414
|
+
case "DATETIME": return datePrecision(shape.options) === "DATE" ? "'2026-03-14'" : "'2026-03-14T09:30:00.000Z'";
|
|
415
|
+
case "TIME": return "'09:30:00'";
|
|
416
|
+
case "SELECT": return choiceEntries(shape.options) === null ? null : `{ id: '${BLANK_ID}' }`;
|
|
417
|
+
case "LINKED_RECORD": return `{ id: '${BLANK_ID}' }`;
|
|
418
|
+
case "ATTACHMENT": return "{ url: 'https://example.com/file.pdf' }";
|
|
419
|
+
case "USER": return `{ id: '${BLANK_ID}', name: 'text', email: 'name@example.com', avatarUrl: null }`;
|
|
420
|
+
case "DATE_RANGE": return "{ from: '2026-03-14', to: '2026-03-21' }";
|
|
421
|
+
default: return null;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/** A number inside the field's own bounds, at the field's own precision. */
|
|
425
|
+
function numberExample(options) {
|
|
426
|
+
const min = option(options, "min");
|
|
427
|
+
const max = option(options, "max");
|
|
428
|
+
const precision = option(options, "precision");
|
|
429
|
+
const low = typeof min === "number" ? min : null;
|
|
430
|
+
const high = typeof max === "number" ? max : null;
|
|
431
|
+
const base = low !== null && high !== null ? (low + high) / 2 : low !== null ? low + 8.5 : high ?? 8.5;
|
|
432
|
+
return Number(base.toFixed(typeof precision === "number" ? precision : 1));
|
|
433
|
+
}
|
|
434
|
+
function option(options, name) {
|
|
435
|
+
return options === null ? void 0 : options[name];
|
|
436
|
+
}
|
|
437
|
+
function isValueShape(value) {
|
|
438
|
+
if (typeof value !== "object" || value === null) return false;
|
|
439
|
+
return typeof value.type === "string";
|
|
440
|
+
}
|
|
441
|
+
function nameOf(datasourceId, context) {
|
|
442
|
+
return context.names.get(datasourceId) ?? panic(`No type name for datasource ${datasourceId}`);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Always quoted, even where an identifier would do. Find All References on a field id at a
|
|
446
|
+
* `defineAction` call site matches string literals and quoted property names, so a bare key is
|
|
447
|
+
* invisible to it and the id no longer navigates here.
|
|
448
|
+
*/
|
|
449
|
+
function key(name) {
|
|
450
|
+
return quote(name);
|
|
451
|
+
}
|
|
452
|
+
function quote(value) {
|
|
453
|
+
return JSON.stringify(value);
|
|
454
|
+
}
|
|
455
|
+
function doc(lines) {
|
|
456
|
+
return [
|
|
457
|
+
"/**",
|
|
458
|
+
...lines.map((line) => line === "" ? " *" : ` * ${safe(line)}`),
|
|
459
|
+
" */"
|
|
460
|
+
].join("\n");
|
|
461
|
+
}
|
|
462
|
+
/** A formula or a field name can contain the comment terminator. */
|
|
463
|
+
function safe(line) {
|
|
464
|
+
return line.replaceAll("*/", "* /");
|
|
465
|
+
}
|
|
466
|
+
function indent(block) {
|
|
467
|
+
return block.split("\n").map((line) => line === "" ? line : ` ${line}`).join("\n");
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
//#endregion
|
|
471
|
+
export { renderDatasourceTypes };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { renderDatasourceTypes } from "./datasource-types.js";
|
|
2
|
+
import { renderUserGroups } from "./user-groups.js";
|
|
3
|
+
|
|
4
|
+
//#region src/codegen/softr-env.ts
|
|
5
|
+
function renderSoftrEnv(input, sdkVersion) {
|
|
6
|
+
const { datasourceTypes, interfaces } = renderDatasourceTypes(input.datasources);
|
|
7
|
+
return [
|
|
8
|
+
header(sdkVersion),
|
|
9
|
+
"",
|
|
10
|
+
PRELUDE,
|
|
11
|
+
"",
|
|
12
|
+
datasourceTypes,
|
|
13
|
+
"",
|
|
14
|
+
"declare module \"@softr/sdk\" {",
|
|
15
|
+
indent(interfaces),
|
|
16
|
+
"",
|
|
17
|
+
indent(renderUserGroups(input.userGroupIds)),
|
|
18
|
+
"}",
|
|
19
|
+
""
|
|
20
|
+
].join("\n");
|
|
21
|
+
}
|
|
22
|
+
function indent(block) {
|
|
23
|
+
return block.split("\n").map((line) => line === "" ? line : ` ${line}`).join("\n");
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The `export {}` is what makes the generated file a module, and it has to stay. In a file with
|
|
27
|
+
* no top-level import or export, `declare module "@softr/sdk"` is not an augmentation, it is an
|
|
28
|
+
* ambient declaration that replaces the package, and every export the block does not name
|
|
29
|
+
* disappears from the app. Nothing else in the file is exported, so this is the only thing
|
|
30
|
+
* holding the file's module-ness up.
|
|
31
|
+
*/
|
|
32
|
+
function header(sdkVersion) {
|
|
33
|
+
return `/**
|
|
34
|
+
* THIS FILE IS AUTO GENERATED, DO NOT EDIT THIS MANUALLY
|
|
35
|
+
*
|
|
36
|
+
* Generated by @softr/sdk@${sdkVersion}
|
|
37
|
+
* This file can be regenerated by running \`npx @softr/sdk generate\`
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export {};`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The shared value aliases, unexported.
|
|
44
|
+
*
|
|
45
|
+
* They are the same for every project, so they belong in the SDK, which does not export them
|
|
46
|
+
* yet. When it does, they move into the import at the top and this block goes.
|
|
47
|
+
*
|
|
48
|
+
* The formatted strings are all `string`, and the format is a docstring rather than a template
|
|
49
|
+
* literal type. A template literal would reject a value read out of a form input or a
|
|
50
|
+
* `useState`, which is where these come from, so every call site would need a cast and the type
|
|
51
|
+
* would buy nothing. The data utilities validate the format with zod at the point of the write
|
|
52
|
+
* instead, which reports a bad date to the user rather than to the compiler.
|
|
53
|
+
*/
|
|
54
|
+
const PRELUDE = `/** A date, as \`2026-03-14\`. */
|
|
55
|
+
type IsoDate = string;
|
|
56
|
+
|
|
57
|
+
/** An ISO 8601 instant, as \`2026-03-14T09:30:00.000Z\`. */
|
|
58
|
+
type IsoDateTime = string;
|
|
59
|
+
|
|
60
|
+
/** A time, as \`09:30:00\`. */
|
|
61
|
+
type IsoTime = string;
|
|
62
|
+
|
|
63
|
+
type EmailAddress = string;
|
|
64
|
+
type PhoneNumber = string;
|
|
65
|
+
type HttpUrl = string;
|
|
66
|
+
|
|
67
|
+
type LinkedRecord<T = string> = {
|
|
68
|
+
id: string;
|
|
69
|
+
label: T | null;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type LinkedRecordInput = { id: string };
|
|
73
|
+
|
|
74
|
+
type WorkspaceUser = {
|
|
75
|
+
id: string;
|
|
76
|
+
name: string;
|
|
77
|
+
email: EmailAddress;
|
|
78
|
+
avatarUrl: string | null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
type Attachment = {
|
|
82
|
+
id: string | null;
|
|
83
|
+
filename: string | null;
|
|
84
|
+
type: string | null;
|
|
85
|
+
url: string;
|
|
86
|
+
expiresAt: IsoDateTime | null;
|
|
87
|
+
size?: number;
|
|
88
|
+
thumbnails?: Array<{ size: "small" | "medium" | "large"; url: string }>;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
type AttachmentInput = {
|
|
92
|
+
url: string;
|
|
93
|
+
filename?: string | null;
|
|
94
|
+
id?: string | null;
|
|
95
|
+
type?: string | null;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
type SelectChoice = { id: string; label: string | null };
|
|
99
|
+
|
|
100
|
+
type SelectChoiceInput = { id: string };`;
|
|
101
|
+
|
|
102
|
+
//#endregion
|
|
103
|
+
export { renderSoftrEnv };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/codegen/user-groups.ts
|
|
2
|
+
function renderUserGroups(ids) {
|
|
3
|
+
return [
|
|
4
|
+
"interface SoftrUserGroups {",
|
|
5
|
+
` ids: ${ids.length === 0 ? "never" : ids.map((id) => JSON.stringify(id)).join(" | ")};`,
|
|
6
|
+
"}"
|
|
7
|
+
].join("\n");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
//#endregion
|
|
11
|
+
export { renderUserGroups };
|
package/dist/core/integration.js
CHANGED