@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,82 @@
|
|
|
1
|
+
import { INTERNAL_KIND } from "./filter.js";
|
|
2
|
+
|
|
3
|
+
//#region src/datasource/fields.ts
|
|
4
|
+
function makeFieldSet(aliases) {
|
|
5
|
+
const set = {};
|
|
6
|
+
for (const alias of aliases) set[alias] = makeChain(alias);
|
|
7
|
+
set["$and"] = (...members) => group("AND", members);
|
|
8
|
+
set["$or"] = (...members) => group("OR", members);
|
|
9
|
+
return set;
|
|
10
|
+
}
|
|
11
|
+
function makeSortSet(aliases) {
|
|
12
|
+
const set = {};
|
|
13
|
+
for (const alias of aliases) set[alias] = {
|
|
14
|
+
asc: {
|
|
15
|
+
sortingField: alias,
|
|
16
|
+
sortType: "ASC",
|
|
17
|
+
[INTERNAL_KIND]: "ORDER_BY"
|
|
18
|
+
},
|
|
19
|
+
desc: {
|
|
20
|
+
sortingField: alias,
|
|
21
|
+
sortType: "DESC",
|
|
22
|
+
[INTERNAL_KIND]: "ORDER_BY"
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
return set;
|
|
26
|
+
}
|
|
27
|
+
/** Ands a list of filters into one group, which is what an accumulated `where` needs. */
|
|
28
|
+
function andAll(filters) {
|
|
29
|
+
return group("AND", filters);
|
|
30
|
+
}
|
|
31
|
+
function group(logicalOperator, filters) {
|
|
32
|
+
return {
|
|
33
|
+
logicalOperator,
|
|
34
|
+
filters,
|
|
35
|
+
[INTERNAL_KIND]: "LOGICAL_GROUP"
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* One chain per alias, carrying every operator whatever the alias reads as. Only the types
|
|
40
|
+
* narrow: a call the types reject cannot reach here, so there is nothing for a per-type
|
|
41
|
+
* runtime chain to catch.
|
|
42
|
+
*/
|
|
43
|
+
function makeChain(subject) {
|
|
44
|
+
return {
|
|
45
|
+
is: (value) => leaf(subject, "IS", value),
|
|
46
|
+
isNot: (value) => leaf(subject, "IS_NOT", value),
|
|
47
|
+
isEmpty: () => leaf(subject, "IS_EMPTY"),
|
|
48
|
+
isNotEmpty: () => leaf(subject, "IS_NOT_EMPTY"),
|
|
49
|
+
contains: (value) => leaf(subject, "CONTAINS", value),
|
|
50
|
+
startsWith: (value) => leaf(subject, "STARTS_WITH", value),
|
|
51
|
+
endsWith: (value) => leaf(subject, "ENDS_WITH", value),
|
|
52
|
+
isOneOf: (values) => leaf(subject, "IS_ONE_OF", values),
|
|
53
|
+
isNoneOf: (values) => leaf(subject, "IS_NONE_OF", values),
|
|
54
|
+
hasAllOf: (values) => leaf(subject, "HAS_ALL_OF", values),
|
|
55
|
+
gt: (value) => leaf(subject, "GT", value),
|
|
56
|
+
gte: (value) => leaf(subject, "GTE", value),
|
|
57
|
+
lt: (value) => leaf(subject, "LT", value),
|
|
58
|
+
lte: (value) => leaf(subject, "LTE", value),
|
|
59
|
+
between: (start, end) => leaf(subject, "BETWEEN", [start, end]),
|
|
60
|
+
isNotBetween: (start, end) => leaf(subject, "IS_NOT_BETWEEN", [start, end])
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function leaf(subject, operator, value) {
|
|
64
|
+
const probe = Array.isArray(value) ? value[0] : value;
|
|
65
|
+
if (typeof probe === "number") return {
|
|
66
|
+
subject,
|
|
67
|
+
operator,
|
|
68
|
+
value: Array.isArray(value) ? value.map(String) : String(value),
|
|
69
|
+
type: "NUMERIC",
|
|
70
|
+
[INTERNAL_KIND]: "FILTER"
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
subject,
|
|
74
|
+
operator,
|
|
75
|
+
value,
|
|
76
|
+
type: typeof probe === "boolean" ? "BOOLEAN" : Array.isArray(value) ? "ARRAY" : "TEXT",
|
|
77
|
+
[INTERNAL_KIND]: "FILTER"
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
//#endregion
|
|
82
|
+
export { andAll, makeFieldSet, makeSortSet };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region src/datasource/filter.d.ts
|
|
2
|
+
|
|
3
|
+
type LogicalOperator = "AND" | "OR";
|
|
4
|
+
type FilterOperator = "IS" | "IS_NOT" | "EQUAL" | "NOT_EQUAL" | "IS_EMPTY" | "IS_NOT_EMPTY" | "IS_ONE_OF" | "IS_NONE_OF" | "HAS_ALL_OF" | "CONTAINS" | "INLINE_CONTAINS" | "GT" | "LT" | "GTE" | "LTE" | "BETWEEN" | "IS_NOT_BETWEEN" | "DOES_NOT_CONTAINS" | "STARTS_WITH" | "ENDS_WITH";
|
|
5
|
+
type ConditionFieldType = "TEXT" | "BOOLEAN" | "NUMERIC" | "ARRAY" | "DATE";
|
|
6
|
+
interface Filter {
|
|
7
|
+
subject: string;
|
|
8
|
+
operator: FilterOperator;
|
|
9
|
+
value?: unknown;
|
|
10
|
+
type: ConditionFieldType;
|
|
11
|
+
}
|
|
12
|
+
type ConstructableFilter = Filter & {
|
|
13
|
+
[INTERNAL_KIND]: "FILTER";
|
|
14
|
+
};
|
|
15
|
+
type ConstructableFilterGroup = {
|
|
16
|
+
logicalOperator: LogicalOperator;
|
|
17
|
+
filters: Array<ConstructableFilter | ConstructableFilterGroup>;
|
|
18
|
+
[INTERNAL_KIND]: "LOGICAL_GROUP";
|
|
19
|
+
};
|
|
20
|
+
interface SortingOptionItem {
|
|
21
|
+
sortingField: string;
|
|
22
|
+
sortType: "ASC" | "DESC";
|
|
23
|
+
}
|
|
24
|
+
type ConstructableOrderBy = SortingOptionItem & {
|
|
25
|
+
[INTERNAL_KIND]: "ORDER_BY";
|
|
26
|
+
};
|
|
27
|
+
type AnyConstructable = ConstructableFilter | ConstructableFilterGroup;
|
|
28
|
+
declare const INTERNAL_KIND: unique symbol;
|
|
29
|
+
//#endregion
|
|
30
|
+
export { AnyConstructable, ConstructableFilter, ConstructableFilterGroup, ConstructableOrderBy };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { assert } from "../assertions.js";
|
|
2
|
+
|
|
3
|
+
//#region src/datasource/filter.ts
|
|
4
|
+
const INTERNAL_KIND = Symbol("KIND");
|
|
5
|
+
/**
|
|
6
|
+
* Converts filter utility conditions to a format our backend supports.
|
|
7
|
+
*
|
|
8
|
+
* A leaf becomes a group of one, because the wire format has no way to hold a bare
|
|
9
|
+
* condition and a group side by side at the same level.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```tsx
|
|
13
|
+
* const json = buildFilterCriteria(fields.title.contains("hello"));
|
|
14
|
+
* // result
|
|
15
|
+
* {
|
|
16
|
+
* "filterCriteria": {
|
|
17
|
+
* "filterConditionGroup": {
|
|
18
|
+
* "logicalOperator": "AND"
|
|
19
|
+
* "filterGroups": [{
|
|
20
|
+
* "logicalOperator": "AND",
|
|
21
|
+
* "filters": [{
|
|
22
|
+
* "subject": "title",
|
|
23
|
+
* "type": "TEXT",
|
|
24
|
+
* "operator": "CONTAINS",
|
|
25
|
+
* "value": "hello"
|
|
26
|
+
* }],
|
|
27
|
+
* }]
|
|
28
|
+
* }
|
|
29
|
+
* }
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
function buildFilterCriteria(input) {
|
|
35
|
+
assert(INTERNAL_KIND in input, "a filter must come from the callback a read hands you");
|
|
36
|
+
if (isFilter(input)) return { filterConditionGroup: {
|
|
37
|
+
logicalOperator: "AND",
|
|
38
|
+
filterGroups: [{
|
|
39
|
+
logicalOperator: "AND",
|
|
40
|
+
filters: [stripInternalKind(input)]
|
|
41
|
+
}]
|
|
42
|
+
} };
|
|
43
|
+
if (isFilterGroup(input)) {
|
|
44
|
+
if (input.filters.length === 0) return { filterConditionGroup: {
|
|
45
|
+
logicalOperator: input.logicalOperator,
|
|
46
|
+
filterGroups: []
|
|
47
|
+
} };
|
|
48
|
+
return { filterConditionGroup: {
|
|
49
|
+
logicalOperator: input.logicalOperator,
|
|
50
|
+
filterGroups: input.filters.map((inner) => {
|
|
51
|
+
if (isFilter(inner)) return {
|
|
52
|
+
logicalOperator: "AND",
|
|
53
|
+
filters: [stripInternalKind(inner)]
|
|
54
|
+
};
|
|
55
|
+
if (!inner.filters.every(isFilter)) throw new Error("Cannot nest filter groups more than three levels deep.");
|
|
56
|
+
return {
|
|
57
|
+
logicalOperator: inner.logicalOperator,
|
|
58
|
+
filters: inner.filters.map(stripInternalKind)
|
|
59
|
+
};
|
|
60
|
+
})
|
|
61
|
+
} };
|
|
62
|
+
}
|
|
63
|
+
throw new Error("Unknown filter kind: " + input[INTERNAL_KIND]);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Converts order-by utility conditions to a format our backend supports
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* single orderBy
|
|
70
|
+
* ```tsx
|
|
71
|
+
* const orderBy = buildOrderBy(sorts.title.asc);
|
|
72
|
+
* // result: [{ sortingField: "title", sortType: "ASC" }]
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* multiple orderBys
|
|
77
|
+
* ```tsx
|
|
78
|
+
* const orderBy = buildOrderBy([sorts.createdAt.desc, sorts.title.asc]);
|
|
79
|
+
* // result: [
|
|
80
|
+
* // { sortingField: "createdAt", sortType: "DESC" },
|
|
81
|
+
* // { sortingField: "title", sortType: "ASC" }
|
|
82
|
+
* // ]
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
function buildOrderBy(input) {
|
|
86
|
+
const orderBys = Array.isArray(input) ? input : [input];
|
|
87
|
+
assert(orderBys.every((ob) => ob[INTERNAL_KIND] === "ORDER_BY"), "a sort must come from the callback a read hands you");
|
|
88
|
+
return orderBys.map((orderBy) => ({
|
|
89
|
+
sortingField: orderBy.sortingField,
|
|
90
|
+
sortType: orderBy.sortType
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
function dealiasOrderBy(orderbys, aliases) {
|
|
94
|
+
return orderbys.map((orderBy) => {
|
|
95
|
+
const subject = aliases[orderBy.sortingField];
|
|
96
|
+
if (!subject) throw new Error(`Could not find an alias for subject "${orderBy.sortingField}"`);
|
|
97
|
+
return {
|
|
98
|
+
...orderBy,
|
|
99
|
+
sortingField: subject
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Replaces filter subjects with their aliased values.
|
|
105
|
+
*
|
|
106
|
+
* Throws an error if any filter subject does not have a corresponding alias in the provided mapping.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```tsx
|
|
110
|
+
* const criteria = buildFilterCriteria(fields.name.contains("John"));
|
|
111
|
+
* const aliases = { name: "abcxy123" };
|
|
112
|
+
* const dealiased = dealiasFilterCriteria(criteria, aliases);
|
|
113
|
+
* // Result: filter subject is now "abcxyz123" instead of "name"
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
function dealiasFilterCriteria(criteria, aliases) {
|
|
117
|
+
if (!criteria.filterConditionGroup) return criteria;
|
|
118
|
+
return { filterConditionGroup: {
|
|
119
|
+
...criteria.filterConditionGroup,
|
|
120
|
+
filterGroups: criteria.filterConditionGroup.filterGroups.map((group) => ({
|
|
121
|
+
...group,
|
|
122
|
+
filters: group.filters.map(dealiasFilter)
|
|
123
|
+
}))
|
|
124
|
+
} };
|
|
125
|
+
function dealiasFilter(filter) {
|
|
126
|
+
const subject = aliases[filter.subject];
|
|
127
|
+
assert(subject, () => `Could not find an alias for subject "${filter.subject}"`);
|
|
128
|
+
return {
|
|
129
|
+
...filter,
|
|
130
|
+
subject
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function stripInternalKind(item) {
|
|
135
|
+
const { [INTERNAL_KIND]: _,...rest } = item;
|
|
136
|
+
return rest;
|
|
137
|
+
}
|
|
138
|
+
function isFilterGroup(item) {
|
|
139
|
+
return item[INTERNAL_KIND] === "LOGICAL_GROUP";
|
|
140
|
+
}
|
|
141
|
+
function isFilter(item) {
|
|
142
|
+
return item[INTERNAL_KIND] === "FILTER";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
//#endregion
|
|
146
|
+
export { INTERNAL_KIND, buildFilterCriteria, buildOrderBy, dealiasFilterCriteria, dealiasOrderBy };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Action, ActionData, ActionRecord, ActionShape, ReadShape } from "../action.js";
|
|
2
|
+
import { AnyConstructable, ConstructableOrderBy } from "./filter.js";
|
|
3
|
+
import { FieldSet, SortSet } from "./fields.js";
|
|
4
|
+
|
|
5
|
+
//#region src/datasource/record.d.ts
|
|
6
|
+
type GetOptions = {
|
|
7
|
+
id: string;
|
|
8
|
+
};
|
|
9
|
+
type WhereCallback<TShape extends ReadShape> = (q: FieldSet<TShape>) => AnyConstructable;
|
|
10
|
+
type OrderByCallback<TShape extends ReadShape> = (q: SortSet<ActionRecord<TShape>>) => ConstructableOrderBy | Array<ConstructableOrderBy>;
|
|
11
|
+
type ListBuilder<TShape extends ReadShape> = {
|
|
12
|
+
/**
|
|
13
|
+
* Narrows down which records come back.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ds.list(posts).where((q) => q.title.contains("cafe"))
|
|
17
|
+
*/
|
|
18
|
+
where(build: WhereCallback<TShape>): ListBuilder<TShape>;
|
|
19
|
+
/**
|
|
20
|
+
* Sorts the records.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ds.list(posts).orderBy((q) => q.publishedAt.asc)
|
|
24
|
+
*
|
|
25
|
+
* An array can be provided to solve tie-breakers.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ds.list(posts).orderBy((q) => [q.views.desc, q.publishedAt.asc])
|
|
29
|
+
*/
|
|
30
|
+
orderBy(build: OrderByCallback<TShape>): ListBuilder<TShape>;
|
|
31
|
+
/** How many records to return. Defaults to 20, capped at 100. */
|
|
32
|
+
limit(count: number): ListBuilder<TShape>;
|
|
33
|
+
/**
|
|
34
|
+
* Where the page starts. Pass the `nextOffset` of a previous result, or `null` to start
|
|
35
|
+
* over. A result with `nextOffset: null` is the last page.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* const first = await ds.list(posts).limit(50)
|
|
39
|
+
* const second = await ds.list(posts).limit(50).offset(first.nextOffset)
|
|
40
|
+
*/
|
|
41
|
+
offset(offset: number | null): ListBuilder<TShape>;
|
|
42
|
+
} & Promise<ListResult<SoftrRecord<ActionRecord<TShape>>>>;
|
|
43
|
+
/**
|
|
44
|
+
* One record, fetched when awaited. There is no `orderBy`, `limit` or `offset`. The record is
|
|
45
|
+
* picked by id, so there is nothing to order or page through.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* const post = await ds.get(postDetail, { id: params.postId })
|
|
49
|
+
*/
|
|
50
|
+
type GetBuilder<TShape extends ReadShape> = {
|
|
51
|
+
/**
|
|
52
|
+
* Narrows which record the id may resolve to. A record that does not match the filter
|
|
53
|
+
* reads as `null`, the same as one that does not exist.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* const post = await ds
|
|
57
|
+
* .get(postDetail, { id: params.postId })
|
|
58
|
+
* .where((q) => q.author.is(user.id))
|
|
59
|
+
*/
|
|
60
|
+
where(build: WhereCallback<TShape>): GetBuilder<TShape>;
|
|
61
|
+
} & Promise<SoftrRecord<ActionRecord<TShape>> | null>;
|
|
62
|
+
interface SoftrRecord<TFields extends Record<string, unknown> = Record<string, unknown>> {
|
|
63
|
+
id: string;
|
|
64
|
+
fields: TFields;
|
|
65
|
+
}
|
|
66
|
+
interface ListResult<TRecord extends SoftrRecord = SoftrRecord> {
|
|
67
|
+
records: TRecord[];
|
|
68
|
+
total: number;
|
|
69
|
+
nextOffset: number | null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Queries a list of records from the datasource.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* const { records, total, nextOffset } = await ds
|
|
76
|
+
* .list(posts)
|
|
77
|
+
* .where((q) => q.publishedAt.lte(today))
|
|
78
|
+
* .orderBy((q) => q.publishedAt.desc)
|
|
79
|
+
* .limit(50)
|
|
80
|
+
*/
|
|
81
|
+
declare function listRecords<TShape extends ReadShape>(query: Action<TShape>): ListBuilder<TShape>;
|
|
82
|
+
/**
|
|
83
|
+
* Gets a single record by ID.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* const record = await ds
|
|
87
|
+
* .get(posts, { id: "record-id" })
|
|
88
|
+
* .where((q) => q.publishedAt.lte(today))
|
|
89
|
+
*
|
|
90
|
+
* A filter may also be provided to narrow down
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* const record = await ds
|
|
94
|
+
* .get(posts, { id: "record-id" })
|
|
95
|
+
* .where((q) => q.publishedAt.lte(today))
|
|
96
|
+
*/
|
|
97
|
+
declare function getRecord<TShape extends ReadShape>(query: Action<TShape>, options: GetOptions): GetBuilder<TShape>;
|
|
98
|
+
/**
|
|
99
|
+
* Creates a record and returns it.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* const record = await ds.create(newPost, {
|
|
103
|
+
* data: { title: "Cafes", publishedAt: today },
|
|
104
|
+
* })
|
|
105
|
+
*/
|
|
106
|
+
declare function createRecord<TShape extends ActionShape & {
|
|
107
|
+
operation: "create";
|
|
108
|
+
}>(action: Action<TShape>, options: {
|
|
109
|
+
data: ActionData<TShape>;
|
|
110
|
+
}): Promise<SoftrRecord<ActionRecord<TShape>>>;
|
|
111
|
+
/**
|
|
112
|
+
* Updates a record by ID and returns it. Only the fields in `data` change.
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* const record = await ds.update(editPost, {
|
|
116
|
+
* id: "record-id",
|
|
117
|
+
* data: { title: "Cafes" },
|
|
118
|
+
* })
|
|
119
|
+
*/
|
|
120
|
+
declare function updateRecord<TShape extends ActionShape & {
|
|
121
|
+
operation: "update";
|
|
122
|
+
}>(action: Action<TShape>, options: {
|
|
123
|
+
id: string;
|
|
124
|
+
data: ActionData<TShape>;
|
|
125
|
+
}): Promise<SoftrRecord<ActionRecord<TShape>>>;
|
|
126
|
+
/**
|
|
127
|
+
* Deletes a record by ID.
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* await ds.delete(removePost, { id: "record-id" })
|
|
131
|
+
*/
|
|
132
|
+
declare function deleteRecord<TShape extends ActionShape & {
|
|
133
|
+
operation: "delete";
|
|
134
|
+
}>(action: Action<TShape>, options: {
|
|
135
|
+
id: string;
|
|
136
|
+
}): Promise<void>;
|
|
137
|
+
//#endregion
|
|
138
|
+
export { WhereCallback, createRecord, deleteRecord, getRecord, listRecords, updateRecord };
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { assert, panic } from "../assertions.js";
|
|
2
|
+
import { isAction } from "../action.js";
|
|
3
|
+
import { readUserTimezone } from "../cookies.js";
|
|
4
|
+
import { readSessionToken } from "../auth/session.js";
|
|
5
|
+
import { buildFilterCriteria, buildOrderBy, dealiasFilterCriteria, dealiasOrderBy } from "./filter.js";
|
|
6
|
+
import { andAll, makeFieldSet, makeSortSet } from "./fields.js";
|
|
7
|
+
|
|
8
|
+
//#region src/datasource/record.ts
|
|
9
|
+
const MAX_RECORD_COUNT = 100;
|
|
10
|
+
const dsUrl = (datasourceId) => {
|
|
11
|
+
const datasourceApiUrl = process.env.SOFTR_DATASOURCE_API_URL;
|
|
12
|
+
if (!datasourceApiUrl) panic("SOFTR_DATASOURCE_API_URL is not set.");
|
|
13
|
+
const applicationId = process.env.SOFTR_APPLICATION_ID;
|
|
14
|
+
if (!applicationId) panic("SOFTR_APPLICATION_ID is not set.");
|
|
15
|
+
return `${withoutTrailingSlash(datasourceApiUrl)}/v1/datasource/vibe/applications/${applicationId}/datasources/${datasourceId}`;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Queries a list of records from the datasource.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const { records, total, nextOffset } = await ds
|
|
22
|
+
* .list(posts)
|
|
23
|
+
* .where((q) => q.publishedAt.lte(today))
|
|
24
|
+
* .orderBy((q) => q.publishedAt.desc)
|
|
25
|
+
* .limit(50)
|
|
26
|
+
*/
|
|
27
|
+
function listRecords(query) {
|
|
28
|
+
const aliases = Object.keys(resolveTarget(query, "read").select);
|
|
29
|
+
const args = {
|
|
30
|
+
filters: [],
|
|
31
|
+
orderBy: []
|
|
32
|
+
};
|
|
33
|
+
let page;
|
|
34
|
+
const run = () => page ??= executeList(query, args);
|
|
35
|
+
const builder = {
|
|
36
|
+
[Symbol.toStringTag]: "RecordListing",
|
|
37
|
+
where(build) {
|
|
38
|
+
args.filters.push(build(makeFieldSet(aliases)));
|
|
39
|
+
return builder;
|
|
40
|
+
},
|
|
41
|
+
orderBy(build) {
|
|
42
|
+
const sorting = build(makeSortSet(aliases));
|
|
43
|
+
args.orderBy.push(...Array.isArray(sorting) ? sorting : [sorting]);
|
|
44
|
+
return builder;
|
|
45
|
+
},
|
|
46
|
+
limit(count) {
|
|
47
|
+
args.limit = count;
|
|
48
|
+
return builder;
|
|
49
|
+
},
|
|
50
|
+
offset(offset) {
|
|
51
|
+
args.offset = offset;
|
|
52
|
+
return builder;
|
|
53
|
+
},
|
|
54
|
+
then(onfulfilled, onrejected) {
|
|
55
|
+
return run().then(onfulfilled, onrejected);
|
|
56
|
+
},
|
|
57
|
+
catch(onrejected) {
|
|
58
|
+
return run().catch(onrejected);
|
|
59
|
+
},
|
|
60
|
+
finally(onfinally) {
|
|
61
|
+
return run().finally(onfinally);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
return builder;
|
|
65
|
+
}
|
|
66
|
+
async function executeList(query, args) {
|
|
67
|
+
const { datasourceId, select } = resolveTarget(query, "read");
|
|
68
|
+
const count = Math.min(args.limit ?? 20, MAX_RECORD_COUNT);
|
|
69
|
+
const offset = args.offset ?? null;
|
|
70
|
+
const filterCriteria = args.filters.length ? dealiasFilterCriteria(buildFilterCriteria(andAll(args.filters)), select) : {};
|
|
71
|
+
const sortingOption = args.orderBy.length ? dealiasOrderBy(buildOrderBy(args.orderBy), select) : void 0;
|
|
72
|
+
const res = await fetch(`${dsUrl(datasourceId)}/records`, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: {
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
"X-User-Timezone": readUserTimezone() ?? "UTC",
|
|
77
|
+
Authorization: `Bearer ${readSessionToken()}`
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
options: { userLocale: "en-US" },
|
|
81
|
+
filterCriteria,
|
|
82
|
+
sortingOption,
|
|
83
|
+
pagingOption: {
|
|
84
|
+
offset,
|
|
85
|
+
count
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
});
|
|
89
|
+
if (!res.ok) throw new Error(`Softr datasource list failed for "${datasourceId}": ${res.status}`);
|
|
90
|
+
const payload = await res.json();
|
|
91
|
+
const nextOffset = payload.offset < payload.total ? payload.offset : null;
|
|
92
|
+
return {
|
|
93
|
+
records: payload.items.map((item) => ({
|
|
94
|
+
id: item.id,
|
|
95
|
+
fields: aliasFields(item.fields, select)
|
|
96
|
+
})),
|
|
97
|
+
total: payload.total,
|
|
98
|
+
nextOffset
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Gets a single record by ID.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* const record = await ds
|
|
106
|
+
* .get(posts, { id: "record-id" })
|
|
107
|
+
* .where((q) => q.publishedAt.lte(today))
|
|
108
|
+
*
|
|
109
|
+
* A filter may also be provided to narrow down
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* const record = await ds
|
|
113
|
+
* .get(posts, { id: "record-id" })
|
|
114
|
+
* .where((q) => q.publishedAt.lte(today))
|
|
115
|
+
*/
|
|
116
|
+
function getRecord(query, options) {
|
|
117
|
+
const aliases = Object.keys(resolveTarget(query, "read").select);
|
|
118
|
+
const filters = [];
|
|
119
|
+
let record;
|
|
120
|
+
const run = () => record ??= executeGet(query, options.id, filters);
|
|
121
|
+
const builder = {
|
|
122
|
+
[Symbol.toStringTag]: "RecordFetch",
|
|
123
|
+
where(build) {
|
|
124
|
+
filters.push(build(makeFieldSet(aliases)));
|
|
125
|
+
return builder;
|
|
126
|
+
},
|
|
127
|
+
then(onfulfilled, onrejected) {
|
|
128
|
+
return run().then(onfulfilled, onrejected);
|
|
129
|
+
},
|
|
130
|
+
catch(onrejected) {
|
|
131
|
+
return run().catch(onrejected);
|
|
132
|
+
},
|
|
133
|
+
finally(onfinally) {
|
|
134
|
+
return run().finally(onfinally);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
return builder;
|
|
138
|
+
}
|
|
139
|
+
async function executeGet(query, id, filters) {
|
|
140
|
+
assert(id, "a record id must not be empty");
|
|
141
|
+
const { datasourceId, select } = resolveTarget(query, "read");
|
|
142
|
+
const filterCriteria = filters.length ? dealiasFilterCriteria(buildFilterCriteria(andAll(filters)), select) : {};
|
|
143
|
+
const res = await fetch(`${dsUrl(datasourceId)}/records/${id}`, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: {
|
|
146
|
+
"Content-Type": "application/json",
|
|
147
|
+
"X-User-Timezone": readUserTimezone() ?? "UTC",
|
|
148
|
+
Authorization: `Bearer ${readSessionToken()}`
|
|
149
|
+
},
|
|
150
|
+
body: JSON.stringify({
|
|
151
|
+
options: { userLocale: "en-US" },
|
|
152
|
+
filterCriteria
|
|
153
|
+
})
|
|
154
|
+
});
|
|
155
|
+
if (!res.ok) throw new Error(`Softr datasource get failed for "${datasourceId}": ${res.status}`);
|
|
156
|
+
const contentLength = res.headers.get("Content-Length");
|
|
157
|
+
if (contentLength && parseInt(contentLength, 10) === 0) return null;
|
|
158
|
+
const record = await res.json();
|
|
159
|
+
return {
|
|
160
|
+
id: record.id,
|
|
161
|
+
fields: aliasFields(record.fields, select)
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Creates a record and returns it.
|
|
166
|
+
*
|
|
167
|
+
* @example
|
|
168
|
+
* const record = await ds.create(newPost, {
|
|
169
|
+
* data: { title: "Cafes", publishedAt: today },
|
|
170
|
+
* })
|
|
171
|
+
*/
|
|
172
|
+
async function createRecord(action, options) {
|
|
173
|
+
const { datasourceId, select } = resolveTarget(action, "create");
|
|
174
|
+
const data = options.data;
|
|
175
|
+
const rawFields = dealiasData(data, select);
|
|
176
|
+
const res = await fetch(`${dsUrl(datasourceId)}/records/new`, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers: {
|
|
179
|
+
"Content-Type": "application/json",
|
|
180
|
+
Authorization: `Bearer ${readSessionToken()}`
|
|
181
|
+
},
|
|
182
|
+
body: JSON.stringify({ fields: rawFields })
|
|
183
|
+
});
|
|
184
|
+
if (!res.ok) throw new Error(`Softr datasource create failed for "${datasourceId}": ${res.status}`);
|
|
185
|
+
const record = await res.json();
|
|
186
|
+
return {
|
|
187
|
+
id: record.id,
|
|
188
|
+
fields: aliasFields(record.fields, select)
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Updates a record by ID and returns it. Only the fields in `data` change.
|
|
193
|
+
*
|
|
194
|
+
* @example
|
|
195
|
+
* const record = await ds.update(editPost, {
|
|
196
|
+
* id: "record-id",
|
|
197
|
+
* data: { title: "Cafes" },
|
|
198
|
+
* })
|
|
199
|
+
*/
|
|
200
|
+
async function updateRecord(action, options) {
|
|
201
|
+
if (!options.id) throw new Error(`Softr datasource update: id must not be empty`);
|
|
202
|
+
const { datasourceId, select } = resolveTarget(action, "update");
|
|
203
|
+
const data = options.data;
|
|
204
|
+
const rawFields = dealiasData(data, select);
|
|
205
|
+
const res = await fetch(`${dsUrl(datasourceId)}/records/${options.id}`, {
|
|
206
|
+
method: "PATCH",
|
|
207
|
+
headers: {
|
|
208
|
+
"Content-Type": "application/json",
|
|
209
|
+
Authorization: `Bearer ${readSessionToken()}`
|
|
210
|
+
},
|
|
211
|
+
body: JSON.stringify({ fields: rawFields })
|
|
212
|
+
});
|
|
213
|
+
if (!res.ok) throw new Error(`Softr datasource update failed for "${datasourceId}": ${res.status}`);
|
|
214
|
+
const record = await res.json();
|
|
215
|
+
return {
|
|
216
|
+
id: record.id,
|
|
217
|
+
fields: aliasFields(record.fields, select)
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Deletes a record by ID.
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* await ds.delete(removePost, { id: "record-id" })
|
|
225
|
+
*/
|
|
226
|
+
async function deleteRecord(action, options) {
|
|
227
|
+
const { datasourceId } = resolveTarget(action, "delete");
|
|
228
|
+
const res = await fetch(`${dsUrl(datasourceId)}/records/${options.id}`, {
|
|
229
|
+
method: "DELETE",
|
|
230
|
+
headers: { Authorization: `Bearer ${readSessionToken()}` }
|
|
231
|
+
});
|
|
232
|
+
if (!res.ok) throw new Error(`Softr datasource delete failed for "${datasourceId}": ${res.status}`);
|
|
233
|
+
}
|
|
234
|
+
function resolveTarget(subject, operation) {
|
|
235
|
+
assert(isAction(subject), "a read or a mutation takes an action from defineAction");
|
|
236
|
+
const action = subject;
|
|
237
|
+
assert(action.operation === operation, () => `action "${action.id}" is declared as ${action.operation}, not ${operation}`);
|
|
238
|
+
return {
|
|
239
|
+
datasourceId: action.datasource,
|
|
240
|
+
select: action.fields ?? {}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function aliasFields(fields, aliases) {
|
|
244
|
+
const result = {};
|
|
245
|
+
for (const [alias, fieldId] of Object.entries(aliases)) result[alias] = fields[fieldId];
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
function dealiasData(data, select) {
|
|
249
|
+
const result = {};
|
|
250
|
+
for (const [alias, value] of Object.entries(data)) result[select[alias] ?? alias] = value;
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
function withoutTrailingSlash(url) {
|
|
254
|
+
return url.replace(/\/+$/, "");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
//#endregion
|
|
258
|
+
export { createRecord, deleteRecord, dsUrl, getRecord, listRecords, resolveTarget, updateRecord };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { q } from "./core/query.js";
|
|
2
1
|
import { SoftrUserGroupId, SoftrUserGroups, User } from "./auth/user.js";
|
|
3
|
-
import {
|
|
4
|
-
import { Permissions } from "./auth/permissions.js";
|
|
2
|
+
import { SoftrDatasources, defineAction } from "./action.js";
|
|
5
3
|
import { SoftrContext } from "./auth/context.js";
|
|
6
|
-
import {
|
|
4
|
+
import { defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError } from "./routes/visibility.js";
|
|
7
5
|
import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser, usePermissions } from "./auth/isomorphic.js";
|
|
8
6
|
import { useUserTimezone } from "./timezone.js";
|
|
9
7
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
10
|
-
export {
|
|
8
|
+
export { DefaultErrorOverlay, type SoftrContext, type SoftrDatasources, type SoftrUserGroupId, type SoftrUserGroups, type User, defineAction, defineVisibility, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, usePermissions, useUserTimezone };
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { defineAction } from "./action.js";
|
|
2
2
|
import { defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError } from "./routes/visibility.js";
|
|
3
3
|
import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser, usePermissions } from "./auth/isomorphic.js";
|
|
4
4
|
import { useUserTimezone } from "./timezone.js";
|
|
5
5
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
6
6
|
|
|
7
|
-
export { DefaultErrorOverlay, defineVisibility, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError,
|
|
7
|
+
export { DefaultErrorOverlay, defineAction, defineVisibility, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, useCurrentUser, usePermissions, useUserTimezone };
|