tsense 0.2.0-next.4 → 0.2.0-next.5
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/filters/index.d.ts +10 -9
- package/dist/filters/index.js +66 -62
- package/dist/filters/relative-dates.d.ts +3 -0
- package/dist/filters/relative-dates.js +20 -0
- package/dist/index.d.ts +8 -7
- package/dist/index.js +5 -4
- package/dist/tsense.d.ts +15 -13
- package/dist/tsense.js +119 -82
- package/dist/types.d.ts +25 -17
- package/package.json +2 -1
package/dist/filters/index.d.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
export {
|
|
2
|
-
|
|
3
|
-
import type {
|
|
4
|
-
import type {
|
|
1
|
+
export { isRelativeDate, resolveRelativeDate } from './relative-dates.js';
|
|
2
|
+
export { deserializeFilter, serializeFilter } from './url.js';
|
|
3
|
+
import type { Type } from 'arktype';
|
|
4
|
+
import type { TSense } from '../tsense.js';
|
|
5
|
+
import type { FilterFor, SearchInput } from '../types.js';
|
|
5
6
|
export type FilterDescriptor<T = Record<string, unknown>> = {
|
|
6
7
|
infer: FilterFor<T>;
|
|
7
8
|
columns: {
|
|
8
9
|
key: keyof T & string;
|
|
9
10
|
label: string;
|
|
10
|
-
type:
|
|
11
|
+
type: 'string' | 'number' | 'boolean' | 'date';
|
|
11
12
|
conditions: {
|
|
12
13
|
key: string;
|
|
13
14
|
label: string;
|
|
@@ -31,10 +32,10 @@ type FilterBuilderReturn<T> = {
|
|
|
31
32
|
describe(): FilterDescriptor<T>;
|
|
32
33
|
schema(): Type<SearchInput<T>>;
|
|
33
34
|
};
|
|
34
|
-
type ColumnType = FilterDescriptor[
|
|
35
|
+
type ColumnType = FilterDescriptor['columns'][number]['type'];
|
|
35
36
|
type FilterBuilderOptions = {
|
|
36
|
-
conditionLabels?: Partial<Record<ColumnType |
|
|
37
|
+
conditionLabels?: Partial<Record<ColumnType | 'enum', Partial<Record<string, string>>>>;
|
|
37
38
|
};
|
|
38
39
|
export declare function createFilterBuilder<T extends Type>(collection: TSense<T>, config: {
|
|
39
|
-
[K in keyof T[
|
|
40
|
-
}, options?: FilterBuilderOptions): FilterBuilderReturn<T[
|
|
40
|
+
[K in keyof T['infer']]?: FilterBuilderFieldConfig<T['infer']>;
|
|
41
|
+
}, options?: FilterBuilderOptions): FilterBuilderReturn<T['infer']>;
|
package/dist/filters/index.js
CHANGED
|
@@ -1,45 +1,46 @@
|
|
|
1
|
-
export {
|
|
2
|
-
|
|
1
|
+
export { isRelativeDate, resolveRelativeDate } from './relative-dates.js';
|
|
2
|
+
export { deserializeFilter, serializeFilter } from './url.js';
|
|
3
|
+
import { type } from 'arktype';
|
|
3
4
|
const tsenseTypeMap = {
|
|
4
|
-
string:
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
int32:
|
|
8
|
-
int64:
|
|
9
|
-
float:
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
bool:
|
|
14
|
-
|
|
15
|
-
auto:
|
|
16
|
-
image:
|
|
5
|
+
string: 'string',
|
|
6
|
+
'string*': 'string',
|
|
7
|
+
'string[]': 'string',
|
|
8
|
+
int32: 'number',
|
|
9
|
+
int64: 'number',
|
|
10
|
+
float: 'number',
|
|
11
|
+
'int32[]': 'number',
|
|
12
|
+
'int64[]': 'number',
|
|
13
|
+
'float[]': 'number',
|
|
14
|
+
bool: 'boolean',
|
|
15
|
+
'bool[]': 'boolean',
|
|
16
|
+
auto: 'string',
|
|
17
|
+
image: 'string',
|
|
17
18
|
};
|
|
18
19
|
const enumConditions = [
|
|
19
|
-
{ key:
|
|
20
|
-
{ key:
|
|
20
|
+
{ key: 'is_in', label: 'is in' },
|
|
21
|
+
{ key: 'is_not_in', label: 'is not in' },
|
|
21
22
|
];
|
|
22
23
|
const conditionsByType = {
|
|
23
24
|
string: [
|
|
24
|
-
{ key:
|
|
25
|
-
{ key:
|
|
25
|
+
{ key: 'equals', label: 'equals' },
|
|
26
|
+
{ key: 'not_equals', label: 'not equals' },
|
|
26
27
|
],
|
|
27
28
|
number: [
|
|
28
|
-
{ key:
|
|
29
|
-
{ key:
|
|
30
|
-
{ key:
|
|
31
|
-
{ key:
|
|
32
|
-
{ key:
|
|
33
|
-
{ key:
|
|
34
|
-
{ key:
|
|
29
|
+
{ key: 'equals', label: 'equals' },
|
|
30
|
+
{ key: 'not_equals', label: 'not equals' },
|
|
31
|
+
{ key: 'gt', label: 'greater than' },
|
|
32
|
+
{ key: 'gte', label: 'greater than or equal' },
|
|
33
|
+
{ key: 'lt', label: 'less than' },
|
|
34
|
+
{ key: 'lte', label: 'less than or equal' },
|
|
35
|
+
{ key: 'between', label: 'between' },
|
|
35
36
|
],
|
|
36
|
-
boolean: [{ key:
|
|
37
|
+
boolean: [{ key: 'equals', label: 'equals' }],
|
|
37
38
|
date: [
|
|
38
|
-
{ key:
|
|
39
|
-
{ key:
|
|
40
|
-
{ key:
|
|
41
|
-
{ key:
|
|
42
|
-
{ key:
|
|
39
|
+
{ key: 'equals', label: 'equals' },
|
|
40
|
+
{ key: 'not_equals', label: 'not equals' },
|
|
41
|
+
{ key: 'gt', label: 'after' },
|
|
42
|
+
{ key: 'lt', label: 'before' },
|
|
43
|
+
{ key: 'between', label: 'between' },
|
|
43
44
|
],
|
|
44
45
|
};
|
|
45
46
|
export function createFilterBuilder(collection, config, options) {
|
|
@@ -47,34 +48,37 @@ export function createFilterBuilder(collection, config, options) {
|
|
|
47
48
|
return {
|
|
48
49
|
schema() {
|
|
49
50
|
const numberOps = type.raw({
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
'not?': 'number',
|
|
52
|
+
'gt?': 'number',
|
|
53
|
+
'gte?': 'number',
|
|
54
|
+
'lt?': 'number',
|
|
55
|
+
'lte?': 'number',
|
|
56
|
+
'notIn?': 'number[]',
|
|
56
57
|
});
|
|
57
58
|
const stringOps = type.raw({
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
'not?': 'string',
|
|
60
|
+
'notIn?': 'string[]',
|
|
60
61
|
});
|
|
61
|
-
const
|
|
62
|
+
const relativeDateUnit = "'day' | 'week' | 'month'";
|
|
63
|
+
const relativeDate = type
|
|
64
|
+
.raw({ startOf: relativeDateUnit })
|
|
65
|
+
.or(type.raw({ endOf: relativeDateUnit }));
|
|
66
|
+
const concreteDateInput = type.raw('number | string | Date');
|
|
67
|
+
const dateInput = concreteDateInput.or(relativeDate);
|
|
68
|
+
const dateArrayInput = dateInput.array();
|
|
62
69
|
const dateOps = type.raw({
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
'not?': dateInput,
|
|
71
|
+
'gt?': dateInput,
|
|
72
|
+
'gte?': dateInput,
|
|
73
|
+
'lt?': dateInput,
|
|
74
|
+
'lte?': dateInput,
|
|
75
|
+
'notIn?': dateArrayInput,
|
|
69
76
|
});
|
|
70
77
|
const fieldSchemas = {
|
|
71
|
-
number: type.raw(
|
|
72
|
-
string: type.raw(
|
|
73
|
-
boolean: type.raw(
|
|
74
|
-
date:
|
|
75
|
-
.raw(dateInput)
|
|
76
|
-
.or(type.raw(`(${dateInput})[]`))
|
|
77
|
-
.or(dateOps),
|
|
78
|
+
number: type.raw('number').or(type.raw('number[]')).or(numberOps),
|
|
79
|
+
string: type.raw('string').or(type.raw('string[]')).or(stringOps),
|
|
80
|
+
boolean: type.raw('boolean'),
|
|
81
|
+
date: dateInput.or(dateArrayInput).or(dateOps),
|
|
78
82
|
};
|
|
79
83
|
const descriptor = this.describe();
|
|
80
84
|
const filterDef = {};
|
|
@@ -83,10 +87,10 @@ export function createFilterBuilder(collection, config, options) {
|
|
|
83
87
|
}
|
|
84
88
|
return type
|
|
85
89
|
.raw({
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
'query?': 'string',
|
|
91
|
+
'filter?': type.raw(filterDef),
|
|
92
|
+
'page?': 'number',
|
|
93
|
+
'limit?': 'number',
|
|
90
94
|
})
|
|
91
95
|
.as();
|
|
92
96
|
},
|
|
@@ -106,8 +110,8 @@ export function createFilterBuilder(collection, config, options) {
|
|
|
106
110
|
const fieldConfig = config[field.name];
|
|
107
111
|
if (!fieldConfig)
|
|
108
112
|
continue;
|
|
109
|
-
const columnType = field.sourceExpression ===
|
|
110
|
-
?
|
|
113
|
+
const columnType = field.sourceExpression === 'Date'
|
|
114
|
+
? 'date'
|
|
111
115
|
: tsenseTypeMap[field.type];
|
|
112
116
|
if (!columnType)
|
|
113
117
|
continue;
|
|
@@ -122,12 +126,12 @@ export function createFilterBuilder(collection, config, options) {
|
|
|
122
126
|
value: v,
|
|
123
127
|
label: fieldConfig.labels?.[v] ?? v,
|
|
124
128
|
}));
|
|
125
|
-
column.conditions = withLabels(enumConditions,
|
|
129
|
+
column.conditions = withLabels(enumConditions, 'enum');
|
|
126
130
|
}
|
|
127
131
|
if (fieldConfig.presets) {
|
|
128
132
|
column.presets = Object.entries(fieldConfig.presets).map(([name, filterOrFn]) => ({
|
|
129
133
|
name,
|
|
130
|
-
filter: (typeof filterOrFn ===
|
|
134
|
+
filter: (typeof filterOrFn === 'function'
|
|
131
135
|
? filterOrFn()
|
|
132
136
|
: filterOrFn),
|
|
133
137
|
}));
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import dayjs from 'dayjs';
|
|
2
|
+
import timezone from 'dayjs/plugin/timezone.js';
|
|
3
|
+
import utc from 'dayjs/plugin/utc.js';
|
|
4
|
+
dayjs.extend(utc);
|
|
5
|
+
dayjs.extend(timezone);
|
|
6
|
+
export function isRelativeDate(value) {
|
|
7
|
+
if (typeof value !== 'object' || value === null || value instanceof Date) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
const obj = value;
|
|
11
|
+
return (('startOf' in obj && typeof obj.startOf === 'string') ||
|
|
12
|
+
('endOf' in obj && typeof obj.endOf === 'string'));
|
|
13
|
+
}
|
|
14
|
+
export function resolveRelativeDate(expr, tz, now) {
|
|
15
|
+
const base = now ? dayjs(now).tz(tz) : dayjs().tz(tz);
|
|
16
|
+
if ('startOf' in expr) {
|
|
17
|
+
return base.startOf(expr.startOf).toDate();
|
|
18
|
+
}
|
|
19
|
+
return base.endOf(expr.endOf).toDate();
|
|
20
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
export type { TsenseFieldMeta, TsenseFieldType } from
|
|
2
|
-
export { rank } from
|
|
3
|
-
export { DateTransformer } from
|
|
4
|
-
export { defaultTransformers } from
|
|
5
|
-
export type { FieldTransformer } from
|
|
6
|
-
export { TSense } from
|
|
7
|
-
export
|
|
1
|
+
export type { TsenseFieldMeta, TsenseFieldType } from './env.js';
|
|
2
|
+
export { rank } from './rank.js';
|
|
3
|
+
export { DateTransformer } from './transformers/date.js';
|
|
4
|
+
export { defaultTransformers } from './transformers/defaults.js';
|
|
5
|
+
export type { FieldTransformer } from './transformers/types.js';
|
|
6
|
+
export { TSense } from './tsense.js';
|
|
7
|
+
export { isRelativeDate, resolveRelativeDate, } from './filters/relative-dates.js';
|
|
8
|
+
export type { ConnectionConfig, DeleteResult, FilterFor, HighlightOptions, NumberFilter, ProjectSearch, RelativeDate, RelativeDateUnit, SearchListOptions, SearchListResult, SearchInput, SearchOptions, ScopedCollection, SearchOptionsPlain, SearchOptionsWithOmit, SearchOptionsWithPick, SearchResult, StringFilter, SyncConfig, SyncOptions, SyncResult, TsenseOptions, UpdateResult, UpsertResult, } from './types.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { rank } from
|
|
2
|
-
export { DateTransformer } from
|
|
3
|
-
export { defaultTransformers } from
|
|
4
|
-
export { TSense } from
|
|
1
|
+
export { rank } from './rank.js';
|
|
2
|
+
export { DateTransformer } from './transformers/date.js';
|
|
3
|
+
export { defaultTransformers } from './transformers/defaults.js';
|
|
4
|
+
export { TSense } from './tsense.js';
|
|
5
|
+
export { isRelativeDate, resolveRelativeDate, } from './filters/relative-dates.js';
|
package/dist/tsense.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { Type } from
|
|
2
|
-
import redaxios from
|
|
3
|
-
import type { DeleteResult, FieldSchema, FilterFor, ProjectSearch, ScopedCollection, SearchListOptions, SearchListResult, SearchOptions, SearchOptionsPlain, SearchResult, SyncOptions, SyncResult, TsenseOptions, UpdateResult, UpsertResult } from
|
|
1
|
+
import type { Type } from 'arktype';
|
|
2
|
+
import redaxios from 'redaxios';
|
|
3
|
+
import type { DeleteResult, FieldSchema, FilterFor, ProjectSearch, ScopedCollection, SearchListOptions, SearchListResult, SearchOptions, SearchOptionsPlain, SearchResult, SyncOptions, SyncResult, TsenseOptions, UpdateResult, UpsertResult } from './types.js';
|
|
4
4
|
declare const redaxiosInstance: {
|
|
5
5
|
<T>(urlOrConfig: string | redaxios.Options, config?: redaxios.Options | undefined, _method?: any, data?: any, _undefined?: undefined): Promise<redaxios.Response<T>>;
|
|
6
6
|
request: (<T_1 = any>(config?: redaxios.Options | undefined) => Promise<redaxios.Response<T_1>>) | (<T_2 = any>(url: string, config?: redaxios.Options | undefined) => Promise<redaxios.Response<T_2>>);
|
|
@@ -64,7 +64,7 @@ export declare class TSense<T extends Type> {
|
|
|
64
64
|
private synced;
|
|
65
65
|
private fieldTransformers;
|
|
66
66
|
private dataSyncConfig?;
|
|
67
|
-
infer: T[
|
|
67
|
+
infer: T['infer'];
|
|
68
68
|
constructor(options: TsenseOptions<T>);
|
|
69
69
|
private getBaseType;
|
|
70
70
|
private inferType;
|
|
@@ -78,28 +78,30 @@ export declare class TSense<T extends Type> {
|
|
|
78
78
|
private buildFilter;
|
|
79
79
|
private validateFilterFields;
|
|
80
80
|
private validateFields;
|
|
81
|
+
private resolveFilterValue;
|
|
82
|
+
private resolveFilterDates;
|
|
81
83
|
private buildFilterExpression;
|
|
82
84
|
private combineFilterExpressions;
|
|
83
85
|
private buildSort;
|
|
84
86
|
create(): Promise<this>;
|
|
85
87
|
drop(): Promise<void>;
|
|
86
|
-
get(id: string): Promise<T[
|
|
88
|
+
get(id: string): Promise<T['infer'] | null>;
|
|
87
89
|
delete(id: string): Promise<boolean>;
|
|
88
90
|
private deleteManyWithFilterBy;
|
|
89
|
-
deleteMany(filter: FilterFor<T[
|
|
90
|
-
update(id: string, data: Partial<T[
|
|
91
|
+
deleteMany(filter: FilterFor<T['infer']>): Promise<DeleteResult>;
|
|
92
|
+
update(id: string, data: Partial<T['infer']>): Promise<T['infer']>;
|
|
91
93
|
private updateManyWithFilterBy;
|
|
92
|
-
updateMany(filter: FilterFor<T[
|
|
94
|
+
updateMany(filter: FilterFor<T['infer']>, data: Partial<T['infer']>): Promise<UpdateResult>;
|
|
93
95
|
private searchWithFilterBy;
|
|
94
|
-
search<const O extends SearchOptions<T[
|
|
96
|
+
search<const O extends SearchOptions<T['infer']> = SearchOptionsPlain<T['infer']>>(options: O): Promise<SearchResult<ProjectSearch<T['infer'], O>>>;
|
|
95
97
|
private searchListWithFilterBy;
|
|
96
|
-
searchList(options: SearchListOptions<T[
|
|
98
|
+
searchList(options: SearchListOptions<T['infer']>): Promise<SearchListResult<T['infer']>>;
|
|
97
99
|
private countWithFilterBy;
|
|
98
|
-
count(filter?: FilterFor<T[
|
|
99
|
-
upsert(docs: T[
|
|
100
|
+
count(filter?: FilterFor<T['infer']>): Promise<number>;
|
|
101
|
+
upsert(docs: T['infer'] | T['infer'][]): Promise<UpsertResult[]>;
|
|
100
102
|
syncData(options?: SyncOptions): Promise<SyncResult>;
|
|
101
103
|
private purgeOrphans;
|
|
102
104
|
exportIds(): Promise<string[]>;
|
|
103
|
-
scoped(baseFilter: FilterFor<T[
|
|
105
|
+
scoped(baseFilter: FilterFor<T['infer']>): ScopedCollection<T['infer']>;
|
|
104
106
|
}
|
|
105
107
|
export {};
|
package/dist/tsense.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import redaxios from
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import redaxios from 'redaxios';
|
|
2
|
+
import { isRelativeDate, resolveRelativeDate, } from './filters/relative-dates.js';
|
|
3
|
+
import { TSenseMigrator } from './migrator.js';
|
|
4
|
+
import { defaultTransformers } from './transformers/defaults.js';
|
|
4
5
|
function chunkArray(arr, size) {
|
|
5
6
|
const chunks = [];
|
|
6
7
|
for (let i = 0; i < arr.length; i += size) {
|
|
@@ -10,8 +11,8 @@ function chunkArray(arr, size) {
|
|
|
10
11
|
}
|
|
11
12
|
const redaxiosInstance = redaxios.default ?? redaxios;
|
|
12
13
|
function escapeFilterValue(value) {
|
|
13
|
-
if (typeof value ===
|
|
14
|
-
return `\`${value.replaceAll(
|
|
14
|
+
if (typeof value === 'string') {
|
|
15
|
+
return `\`${value.replaceAll('`', '')}\``;
|
|
15
16
|
}
|
|
16
17
|
if (Array.isArray(value)) {
|
|
17
18
|
return value.map(escapeFilterValue);
|
|
@@ -24,17 +25,17 @@ const filterOperators = {
|
|
|
24
25
|
gte: (k, v) => `${k}:>=${v}`,
|
|
25
26
|
lt: (k, v) => `${k}:<${v}`,
|
|
26
27
|
lte: (k, v) => `${k}:<=${v}`,
|
|
27
|
-
notIn: (k, v) => `${k}:!=[${v.join(
|
|
28
|
+
notIn: (k, v) => `${k}:!=[${v.join(',')}]`,
|
|
28
29
|
};
|
|
29
30
|
const arkToTsense = {
|
|
30
|
-
string:
|
|
31
|
-
number:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
boolean:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
string: 'string',
|
|
32
|
+
number: 'float',
|
|
33
|
+
'number.integer': 'int64',
|
|
34
|
+
'number % 1': 'int64',
|
|
35
|
+
boolean: 'bool',
|
|
36
|
+
'string[]': 'string[]',
|
|
37
|
+
'number[]': 'float[]',
|
|
38
|
+
'boolean[]': 'bool[]',
|
|
38
39
|
};
|
|
39
40
|
export class TSense {
|
|
40
41
|
options;
|
|
@@ -48,27 +49,27 @@ export class TSense {
|
|
|
48
49
|
this.options = options;
|
|
49
50
|
this.axios = redaxiosInstance.create({
|
|
50
51
|
baseURL: `${options.connection.protocol}://${options.connection.host}:${options.connection.port}`,
|
|
51
|
-
headers: {
|
|
52
|
+
headers: { 'X-TYPESENSE-API-KEY': options.connection.apiKey },
|
|
52
53
|
});
|
|
53
54
|
this.fields = this.extractFields(options.transformers ?? defaultTransformers);
|
|
54
55
|
this.dataSyncConfig = options.dataSync;
|
|
55
56
|
}
|
|
56
57
|
getBaseType(expression, domain) {
|
|
57
|
-
if (domain && domain !==
|
|
58
|
+
if (domain && domain !== 'undefined')
|
|
58
59
|
return domain;
|
|
59
|
-
return expression.replace(/ \| undefined$/,
|
|
60
|
+
return expression.replace(/ \| undefined$/, '');
|
|
60
61
|
}
|
|
61
62
|
inferType(arkType) {
|
|
62
63
|
const direct = arkToTsense[arkType];
|
|
63
64
|
if (direct)
|
|
64
65
|
return direct;
|
|
65
|
-
if (arkType.includes(
|
|
66
|
-
return
|
|
66
|
+
if (arkType.includes('[]'))
|
|
67
|
+
return 'object[]';
|
|
67
68
|
if (arkType.includes("'") || arkType.includes('"'))
|
|
68
|
-
return
|
|
69
|
-
if (arkType.includes(
|
|
70
|
-
return
|
|
71
|
-
return
|
|
69
|
+
return 'string';
|
|
70
|
+
if (arkType.includes('{') || arkType.includes('|'))
|
|
71
|
+
return 'object';
|
|
72
|
+
return 'string';
|
|
72
73
|
}
|
|
73
74
|
serializeDoc(doc) {
|
|
74
75
|
const result = { ...doc };
|
|
@@ -99,7 +100,7 @@ export class TSense {
|
|
|
99
100
|
if (Array.isArray(value)) {
|
|
100
101
|
return value.map((v) => transformer.serialize(v));
|
|
101
102
|
}
|
|
102
|
-
if (typeof value ===
|
|
103
|
+
if (typeof value === 'object' &&
|
|
103
104
|
value !== null &&
|
|
104
105
|
Object.getPrototypeOf(value) === Object.prototype) {
|
|
105
106
|
const result = {};
|
|
@@ -126,7 +127,7 @@ export class TSense {
|
|
|
126
127
|
const branches = prop.value.branches ?? [];
|
|
127
128
|
const enumValues = [];
|
|
128
129
|
for (const branch of branches) {
|
|
129
|
-
if (typeof branch.unit ===
|
|
130
|
+
if (typeof branch.unit === 'string') {
|
|
130
131
|
enumValues.push(branch.unit);
|
|
131
132
|
}
|
|
132
133
|
}
|
|
@@ -142,7 +143,7 @@ export class TSense {
|
|
|
142
143
|
name: prop.key,
|
|
143
144
|
type: transformer.storageType,
|
|
144
145
|
sourceExpression: expression,
|
|
145
|
-
optional: prop.kind ===
|
|
146
|
+
optional: prop.kind === 'optional',
|
|
146
147
|
facet: meta?.facet,
|
|
147
148
|
sort: meta?.sort,
|
|
148
149
|
index: meta?.index,
|
|
@@ -155,7 +156,7 @@ export class TSense {
|
|
|
155
156
|
name: prop.key,
|
|
156
157
|
type,
|
|
157
158
|
sourceExpression: expression,
|
|
158
|
-
optional: prop.kind ===
|
|
159
|
+
optional: prop.kind === 'optional',
|
|
159
160
|
facet: meta?.facet,
|
|
160
161
|
sort: meta?.sort,
|
|
161
162
|
index: meta?.index,
|
|
@@ -179,7 +180,7 @@ export class TSense {
|
|
|
179
180
|
const escapedLte = escapeFilterValue(value.lte);
|
|
180
181
|
const parts = [`${key}:[${escaped}..${escapedLte}]`];
|
|
181
182
|
for (const [op, opValue] of Object.entries(value)) {
|
|
182
|
-
if (op ===
|
|
183
|
+
if (op === 'gte' || op === 'lte' || opValue == null)
|
|
183
184
|
continue;
|
|
184
185
|
const builder = filterOperators[op];
|
|
185
186
|
if (builder) {
|
|
@@ -205,34 +206,34 @@ export class TSense {
|
|
|
205
206
|
const [key, rawValue] = entry;
|
|
206
207
|
if (rawValue == null)
|
|
207
208
|
continue;
|
|
208
|
-
if (key ===
|
|
209
|
+
if (key === 'OR') {
|
|
209
210
|
const orParts = [];
|
|
210
211
|
for (const condition of rawValue) {
|
|
211
212
|
const inner = this.buildFilter(condition);
|
|
212
213
|
if (!inner.length) {
|
|
213
214
|
continue;
|
|
214
215
|
}
|
|
215
|
-
orParts.push(`(${inner.join(
|
|
216
|
+
orParts.push(`(${inner.join('&&')})`);
|
|
216
217
|
}
|
|
217
218
|
if (!orParts.length) {
|
|
218
219
|
continue;
|
|
219
220
|
}
|
|
220
|
-
result.push(`(${orParts.join(
|
|
221
|
+
result.push(`(${orParts.join('||')})`);
|
|
221
222
|
continue;
|
|
222
223
|
}
|
|
223
224
|
const value = this.serializeFilterValue(key, rawValue);
|
|
224
225
|
const escaped = escapeFilterValue(value);
|
|
225
|
-
if (typeof escaped ===
|
|
226
|
-
typeof escaped ===
|
|
227
|
-
typeof escaped ===
|
|
226
|
+
if (typeof escaped === 'string' ||
|
|
227
|
+
typeof escaped === 'number' ||
|
|
228
|
+
typeof escaped === 'boolean') {
|
|
228
229
|
result.push(`${key}:=${escaped}`);
|
|
229
230
|
continue;
|
|
230
231
|
}
|
|
231
232
|
if (Array.isArray(escaped)) {
|
|
232
|
-
result.push(`${key}:[${escaped.join(
|
|
233
|
+
result.push(`${key}:[${escaped.join(',')}]`);
|
|
233
234
|
continue;
|
|
234
235
|
}
|
|
235
|
-
if (typeof value ===
|
|
236
|
+
if (typeof value === 'object' && value !== null) {
|
|
236
237
|
result.push(...this.buildObjectFilter(key, value));
|
|
237
238
|
}
|
|
238
239
|
}
|
|
@@ -247,7 +248,7 @@ export class TSense {
|
|
|
247
248
|
if (value == null) {
|
|
248
249
|
continue;
|
|
249
250
|
}
|
|
250
|
-
if (key ===
|
|
251
|
+
if (key === 'OR') {
|
|
251
252
|
for (const condition of value) {
|
|
252
253
|
this.validateFilterFields(condition);
|
|
253
254
|
}
|
|
@@ -263,43 +264,79 @@ export class TSense {
|
|
|
263
264
|
validateFields(fields) {
|
|
264
265
|
const valid = new Set(this.fields.map((f) => f.name));
|
|
265
266
|
for (const field of fields) {
|
|
266
|
-
if (field !==
|
|
267
|
+
if (field !== 'score' && !valid.has(field)) {
|
|
267
268
|
throw new Error(`INVALID_FIELD: ${field}`);
|
|
268
269
|
}
|
|
269
270
|
}
|
|
270
271
|
}
|
|
272
|
+
resolveFilterValue(value) {
|
|
273
|
+
if (isRelativeDate(value)) {
|
|
274
|
+
return resolveRelativeDate(value, this.options.timezone);
|
|
275
|
+
}
|
|
276
|
+
if (Array.isArray(value)) {
|
|
277
|
+
return value.map((v) => isRelativeDate(v) ? resolveRelativeDate(v, this.options.timezone) : v);
|
|
278
|
+
}
|
|
279
|
+
if (typeof value === 'object' &&
|
|
280
|
+
value !== null &&
|
|
281
|
+
!(value instanceof Date)) {
|
|
282
|
+
const result = {};
|
|
283
|
+
for (const [k, v] of Object.entries(value)) {
|
|
284
|
+
result[k] = this.resolveFilterValue(v);
|
|
285
|
+
}
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
290
|
+
resolveFilterDates(filter) {
|
|
291
|
+
if (!filter || !this.options.timezone) {
|
|
292
|
+
return filter;
|
|
293
|
+
}
|
|
294
|
+
const result = {};
|
|
295
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
296
|
+
if (value == null) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (key === 'OR') {
|
|
300
|
+
result.OR = value.map((f) => this.resolveFilterDates(f));
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
result[key] = this.resolveFilterValue(value);
|
|
304
|
+
}
|
|
305
|
+
return result;
|
|
306
|
+
}
|
|
271
307
|
buildFilterExpression(filter) {
|
|
272
|
-
this.
|
|
273
|
-
|
|
308
|
+
const resolved = this.resolveFilterDates(filter);
|
|
309
|
+
this.validateFilterFields(resolved);
|
|
310
|
+
const parts = this.buildFilter(resolved);
|
|
274
311
|
if (!parts.length) {
|
|
275
312
|
return;
|
|
276
313
|
}
|
|
277
|
-
return `(${parts.join(
|
|
314
|
+
return `(${parts.join('&&')})`;
|
|
278
315
|
}
|
|
279
316
|
combineFilterExpressions(...filters) {
|
|
280
317
|
return filters
|
|
281
318
|
.map((filter) => this.buildFilterExpression(filter))
|
|
282
319
|
.filter((filter) => filter != null)
|
|
283
|
-
.join(
|
|
320
|
+
.join('&&');
|
|
284
321
|
}
|
|
285
322
|
buildSort(options) {
|
|
286
323
|
if (!options.sortBy)
|
|
287
324
|
return;
|
|
288
325
|
const result = [];
|
|
289
326
|
for (const item of options.sortBy) {
|
|
290
|
-
const [field, direction] = item.split(
|
|
291
|
-
if (field ===
|
|
327
|
+
const [field, direction] = item.split(':');
|
|
328
|
+
if (field === 'undefined')
|
|
292
329
|
continue;
|
|
293
|
-
const realField = field ===
|
|
330
|
+
const realField = field === 'score' ? '_text_match' : field;
|
|
294
331
|
result.push(`${realField}:${direction}`);
|
|
295
332
|
}
|
|
296
|
-
return result.join(
|
|
333
|
+
return result.join(',');
|
|
297
334
|
}
|
|
298
335
|
async create() {
|
|
299
|
-
const enableNested = this.fields.some((f) => f.type ===
|
|
336
|
+
const enableNested = this.fields.some((f) => f.type === 'object' || f.type === 'object[]');
|
|
300
337
|
await this.axios({
|
|
301
|
-
method:
|
|
302
|
-
url:
|
|
338
|
+
method: 'POST',
|
|
339
|
+
url: '/collections',
|
|
303
340
|
data: {
|
|
304
341
|
name: this.options.name,
|
|
305
342
|
fields: this.fields,
|
|
@@ -311,14 +348,14 @@ export class TSense {
|
|
|
311
348
|
}
|
|
312
349
|
async drop() {
|
|
313
350
|
await this.axios({
|
|
314
|
-
method:
|
|
351
|
+
method: 'DELETE',
|
|
315
352
|
url: `/collections/${this.options.name}`,
|
|
316
353
|
});
|
|
317
354
|
}
|
|
318
355
|
async get(id) {
|
|
319
356
|
await this.ensureSynced();
|
|
320
357
|
const { data } = await this.axios({
|
|
321
|
-
method:
|
|
358
|
+
method: 'GET',
|
|
322
359
|
url: `/collections/${this.options.name}/documents/${id}`,
|
|
323
360
|
}).catch((e) => {
|
|
324
361
|
if (e.status === 404)
|
|
@@ -330,7 +367,7 @@ export class TSense {
|
|
|
330
367
|
async delete(id) {
|
|
331
368
|
await this.ensureSynced();
|
|
332
369
|
const { data } = await this.axios({
|
|
333
|
-
method:
|
|
370
|
+
method: 'DELETE',
|
|
334
371
|
url: `/collections/${this.options.name}/documents/${id}`,
|
|
335
372
|
}).catch((e) => {
|
|
336
373
|
if (e.status === 404)
|
|
@@ -342,10 +379,10 @@ export class TSense {
|
|
|
342
379
|
async deleteManyWithFilterBy(filterBy) {
|
|
343
380
|
await this.ensureSynced();
|
|
344
381
|
if (!filterBy) {
|
|
345
|
-
throw new Error(
|
|
382
|
+
throw new Error('FILTER_REQUIRED');
|
|
346
383
|
}
|
|
347
384
|
const { data } = await this.axios({
|
|
348
|
-
method:
|
|
385
|
+
method: 'DELETE',
|
|
349
386
|
url: `/collections/${this.options.name}/documents`,
|
|
350
387
|
params: { filter_by: filterBy },
|
|
351
388
|
});
|
|
@@ -358,7 +395,7 @@ export class TSense {
|
|
|
358
395
|
await this.ensureSynced();
|
|
359
396
|
const serialized = this.serializeDoc(data);
|
|
360
397
|
const { data: updated } = await this.axios({
|
|
361
|
-
method:
|
|
398
|
+
method: 'PATCH',
|
|
362
399
|
url: `/collections/${this.options.name}/documents/${id}`,
|
|
363
400
|
data: serialized,
|
|
364
401
|
});
|
|
@@ -367,11 +404,11 @@ export class TSense {
|
|
|
367
404
|
async updateManyWithFilterBy(filterBy, data) {
|
|
368
405
|
await this.ensureSynced();
|
|
369
406
|
if (!filterBy) {
|
|
370
|
-
throw new Error(
|
|
407
|
+
throw new Error('FILTER_REQUIRED');
|
|
371
408
|
}
|
|
372
409
|
const serialized = this.serializeDoc(data);
|
|
373
410
|
const { data: result } = await this.axios({
|
|
374
|
-
method:
|
|
411
|
+
method: 'PATCH',
|
|
375
412
|
url: `/collections/${this.options.name}/documents`,
|
|
376
413
|
params: { filter_by: filterBy },
|
|
377
414
|
data: serialized,
|
|
@@ -389,15 +426,15 @@ export class TSense {
|
|
|
389
426
|
this.validateFields(queryByFields);
|
|
390
427
|
if (options.sortBy) {
|
|
391
428
|
this.validateFields(options.sortBy
|
|
392
|
-
.map((s) => s.split(
|
|
393
|
-
.filter((f) => f !==
|
|
429
|
+
.map((s) => s.split(':')[0])
|
|
430
|
+
.filter((f) => f !== 'undefined'));
|
|
394
431
|
}
|
|
395
432
|
if (options.facetBy) {
|
|
396
433
|
this.validateFields(options.facetBy);
|
|
397
434
|
}
|
|
398
|
-
const queryBy = queryByFields.join(
|
|
435
|
+
const queryBy = queryByFields.join(',');
|
|
399
436
|
const params = {
|
|
400
|
-
q: options.query ??
|
|
437
|
+
q: options.query ?? '*',
|
|
401
438
|
query_by: queryBy,
|
|
402
439
|
};
|
|
403
440
|
const sortBy = this.buildSort(options);
|
|
@@ -409,20 +446,20 @@ export class TSense {
|
|
|
409
446
|
params.page = options.page;
|
|
410
447
|
if (options.limit != null)
|
|
411
448
|
params.per_page = options.limit;
|
|
412
|
-
const facetBy = options.facetBy?.join(
|
|
449
|
+
const facetBy = options.facetBy?.join(',');
|
|
413
450
|
if (facetBy)
|
|
414
451
|
params.facet_by = facetBy;
|
|
415
|
-
if (
|
|
416
|
-
params.include_fields = options.pick.join(
|
|
452
|
+
if ('pick' in options && options.pick) {
|
|
453
|
+
params.include_fields = options.pick.join(',');
|
|
417
454
|
}
|
|
418
|
-
if (
|
|
419
|
-
params.exclude_fields = options.omit.join(
|
|
455
|
+
if ('omit' in options && options.omit) {
|
|
456
|
+
params.exclude_fields = options.omit.join(',');
|
|
420
457
|
}
|
|
421
458
|
const highlight = options.highlight;
|
|
422
|
-
const highlightOpts = typeof highlight ===
|
|
459
|
+
const highlightOpts = typeof highlight === 'object' ? highlight : undefined;
|
|
423
460
|
if (highlightOpts) {
|
|
424
461
|
if (highlightOpts.fields) {
|
|
425
|
-
params.highlight_fields = highlightOpts.fields.join(
|
|
462
|
+
params.highlight_fields = highlightOpts.fields.join(',');
|
|
426
463
|
}
|
|
427
464
|
if (highlightOpts.startTag) {
|
|
428
465
|
params.highlight_start_tag = highlightOpts.startTag;
|
|
@@ -432,7 +469,7 @@ export class TSense {
|
|
|
432
469
|
}
|
|
433
470
|
}
|
|
434
471
|
const { data: res } = await this.axios({
|
|
435
|
-
method:
|
|
472
|
+
method: 'GET',
|
|
436
473
|
url: `/collections/${this.options.name}/documents/search`,
|
|
437
474
|
params,
|
|
438
475
|
});
|
|
@@ -495,19 +532,19 @@ export class TSense {
|
|
|
495
532
|
await this.ensureSynced();
|
|
496
533
|
if (!filterBy) {
|
|
497
534
|
const { data } = await this.axios({
|
|
498
|
-
method:
|
|
535
|
+
method: 'GET',
|
|
499
536
|
url: `/collections/${this.options.name}`,
|
|
500
537
|
});
|
|
501
538
|
return data.num_documents;
|
|
502
539
|
}
|
|
503
540
|
const params = {
|
|
504
|
-
q:
|
|
541
|
+
q: '*',
|
|
505
542
|
query_by: this.options.defaultSearchField,
|
|
506
543
|
per_page: 0,
|
|
507
544
|
filter_by: filterBy,
|
|
508
545
|
};
|
|
509
546
|
const { data } = await this.axios({
|
|
510
|
-
method:
|
|
547
|
+
method: 'GET',
|
|
511
548
|
url: `/collections/${this.options.name}/documents/search`,
|
|
512
549
|
params,
|
|
513
550
|
});
|
|
@@ -528,26 +565,26 @@ export class TSense {
|
|
|
528
565
|
}
|
|
529
566
|
const payload = items
|
|
530
567
|
.map((item) => JSON.stringify(this.serializeDoc(item)))
|
|
531
|
-
.join(
|
|
532
|
-
const params = { action:
|
|
568
|
+
.join('\n');
|
|
569
|
+
const params = { action: 'upsert' };
|
|
533
570
|
if (this.options.batchSize) {
|
|
534
571
|
params.batch_size = this.options.batchSize;
|
|
535
572
|
}
|
|
536
573
|
const { data } = await this.axios({
|
|
537
|
-
method:
|
|
574
|
+
method: 'POST',
|
|
538
575
|
url: `/collections/${this.options.name}/documents/import`,
|
|
539
|
-
headers: {
|
|
576
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
540
577
|
params,
|
|
541
578
|
data: payload,
|
|
542
579
|
});
|
|
543
|
-
if (typeof data ===
|
|
544
|
-
return data.split(
|
|
580
|
+
if (typeof data === 'string') {
|
|
581
|
+
return data.split('\n').map((v) => JSON.parse(v));
|
|
545
582
|
}
|
|
546
583
|
return [data];
|
|
547
584
|
}
|
|
548
585
|
async syncData(options) {
|
|
549
586
|
if (!this.dataSyncConfig) {
|
|
550
|
-
throw new Error(
|
|
587
|
+
throw new Error('DATA_SYNC_NOT_CONFIGURED');
|
|
551
588
|
}
|
|
552
589
|
const chunkSize = options?.chunkSize ?? this.dataSyncConfig.chunkSize ?? 500;
|
|
553
590
|
const ids = options?.ids ?? (await this.dataSyncConfig.getAllIds());
|
|
@@ -584,12 +621,12 @@ export class TSense {
|
|
|
584
621
|
}
|
|
585
622
|
async exportIds() {
|
|
586
623
|
const { data } = await this.axios({
|
|
587
|
-
method:
|
|
624
|
+
method: 'GET',
|
|
588
625
|
url: `/collections/${this.options.name}/documents/export`,
|
|
589
|
-
params: { include_fields:
|
|
626
|
+
params: { include_fields: 'id' },
|
|
590
627
|
});
|
|
591
628
|
return data
|
|
592
|
-
.split(
|
|
629
|
+
.split('\n')
|
|
593
630
|
.filter((line) => line.length)
|
|
594
631
|
.map((line) => JSON.parse(line).id);
|
|
595
632
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Type } from
|
|
2
|
-
import type { FieldTransformer } from
|
|
1
|
+
import type { Type } from 'arktype';
|
|
2
|
+
import type { FieldTransformer } from './transformers/types.js';
|
|
3
3
|
type BaseIfArray<T> = T extends (infer Q)[] ? Q : T;
|
|
4
4
|
export type FieldSchema = {
|
|
5
5
|
name: string;
|
|
@@ -32,7 +32,7 @@ export type SearchApiResponse<T> = {
|
|
|
32
32
|
export type ConnectionConfig = {
|
|
33
33
|
host: string;
|
|
34
34
|
port: number;
|
|
35
|
-
protocol:
|
|
35
|
+
protocol: 'http' | 'https';
|
|
36
36
|
apiKey: string;
|
|
37
37
|
timeout?: number;
|
|
38
38
|
};
|
|
@@ -40,14 +40,22 @@ export type TsenseOptions<T extends Type> = {
|
|
|
40
40
|
name: string;
|
|
41
41
|
schema: T;
|
|
42
42
|
connection: ConnectionConfig;
|
|
43
|
-
defaultSearchField?: keyof T[
|
|
44
|
-
defaultSortingField?: keyof T[
|
|
43
|
+
defaultSearchField?: keyof T['infer'];
|
|
44
|
+
defaultSortingField?: keyof T['infer'];
|
|
45
45
|
batchSize?: number;
|
|
46
46
|
validateOnUpsert?: boolean;
|
|
47
47
|
autoSyncSchema?: boolean;
|
|
48
|
+
timezone?: string;
|
|
48
49
|
transformers?: FieldTransformer[];
|
|
49
|
-
dataSync?: SyncConfig<T[
|
|
50
|
+
dataSync?: SyncConfig<T['infer']>;
|
|
50
51
|
};
|
|
52
|
+
export type RelativeDateUnit = 'day' | 'week' | 'month';
|
|
53
|
+
export type RelativeDate = {
|
|
54
|
+
startOf: RelativeDateUnit;
|
|
55
|
+
} | {
|
|
56
|
+
endOf: RelativeDateUnit;
|
|
57
|
+
};
|
|
58
|
+
type DateValue = Date | RelativeDate;
|
|
51
59
|
export type StringFilter = {
|
|
52
60
|
not?: string;
|
|
53
61
|
notIn?: string[];
|
|
@@ -61,14 +69,14 @@ export type NumberFilter = {
|
|
|
61
69
|
lte?: number;
|
|
62
70
|
};
|
|
63
71
|
type DateFilter = {
|
|
64
|
-
not?:
|
|
65
|
-
notIn?:
|
|
66
|
-
gt?:
|
|
67
|
-
gte?:
|
|
68
|
-
lt?:
|
|
69
|
-
lte?:
|
|
70
|
-
};
|
|
71
|
-
type FilterValueFor<T> = [T] extends [boolean] ? boolean : [T] extends [Date] ?
|
|
72
|
+
not?: DateValue;
|
|
73
|
+
notIn?: DateValue[];
|
|
74
|
+
gt?: DateValue;
|
|
75
|
+
gte?: DateValue;
|
|
76
|
+
lt?: DateValue;
|
|
77
|
+
lte?: DateValue;
|
|
78
|
+
};
|
|
79
|
+
type FilterValueFor<T> = [T] extends [boolean] ? boolean : [T] extends [Date] ? DateValue | DateValue[] | DateFilter : [T] extends [number] ? number | number[] | NumberFilter : [T] extends [string] ? T | T[] | StringFilter : never;
|
|
72
80
|
type SingleFilter<T> = Partial<{
|
|
73
81
|
[K in keyof T]: FilterValueFor<NonNullable<BaseIfArray<T[K]>>>;
|
|
74
82
|
}>;
|
|
@@ -80,12 +88,12 @@ export type HighlightOptions<T> = {
|
|
|
80
88
|
startTag?: string;
|
|
81
89
|
endTag?: string;
|
|
82
90
|
};
|
|
83
|
-
type SortableField<T> = Extract<keyof T, string> |
|
|
91
|
+
type SortableField<T> = Extract<keyof T, string> | 'score';
|
|
84
92
|
export type BaseSearchOptions<T> = {
|
|
85
93
|
query?: string;
|
|
86
94
|
queryBy?: (keyof T)[];
|
|
87
95
|
filter?: FilterFor<T>;
|
|
88
|
-
sortBy?: `${SortableField<T>}:${
|
|
96
|
+
sortBy?: `${SortableField<T>}:${'asc' | 'desc'}`[];
|
|
89
97
|
facetBy?: (keyof T)[];
|
|
90
98
|
page?: number;
|
|
91
99
|
limit?: number;
|
|
@@ -136,7 +144,7 @@ export type SearchListOptions<T> = {
|
|
|
136
144
|
query?: string;
|
|
137
145
|
queryBy?: (keyof T)[];
|
|
138
146
|
filter?: FilterFor<T>;
|
|
139
|
-
sortBy: `${Extract<keyof T, string>}:${
|
|
147
|
+
sortBy: `${Extract<keyof T, string>}:${'asc' | 'desc'}`;
|
|
140
148
|
limit?: number;
|
|
141
149
|
cursor?: string;
|
|
142
150
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tsense",
|
|
3
|
-
"version": "0.2.0-next.
|
|
3
|
+
"version": "0.2.0-next.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Opinionated, fully typed typesense client",
|
|
6
6
|
"keywords": [
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"prepublishOnly": "bun run ci"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
+
"dayjs": "^1.11.20",
|
|
46
47
|
"redaxios": "^0.5.1"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|