chore: model config API accepts mappers but API still needs finishing

This commit is contained in:
Ken Snyder
2022-01-25 06:30:24 -08:00
parent db33076a9b
commit d95db76538
17 changed files with 6198 additions and 387 deletions

View File

@@ -4,9 +4,12 @@
declare module 'vue' {
export interface GlobalComponents {
'AntDesign:fileMarkdownOutlined': typeof import('~icons/ant-design/file-markdown-outlined')['default']
'Bx:bxSearchAlt': typeof import('~icons/bx/bx-search-alt')['default']
'Carbon:document': typeof import('~icons/carbon/document')['default']
'Carbon:errorFilled': typeof import('~icons/carbon/error-filled')['default']
'Carbon:listDropdown': typeof import('~icons/carbon/list-dropdown')['default']
'Carbon:nextFilled': typeof import('~icons/carbon/next-filled')['default']
CarbonLanguage: typeof import('~icons/carbon/language')['default']
CarbonMoon: typeof import('~icons/carbon/moon')['default']
CarbonSun: typeof import('~icons/carbon/sun')['default']
@@ -14,8 +17,10 @@ declare module 'vue' {
'Fluent:databaseSearch24Regular': typeof import('~icons/fluent/database-search24-regular')['default']
Footer: typeof import('./components/Footer.vue')['default']
'Mdi:folderHome': typeof import('~icons/mdi/folder-home')['default']
'Mdi:github': typeof import('~icons/mdi/github')['default']
'Mdi:languageRust': typeof import('~icons/mdi/language-rust')['default']
'Mdi:languageTypescript': typeof import('~icons/mdi/language-typescript')['default']
'Ph:linkLight': typeof import('~icons/ph/link-light')['default']
README: typeof import('./components/README.md')['default']
SearchActions: typeof import('./components/SearchActions.vue')['default']
SearchHit: typeof import('./components/SearchHit.vue')['default']
@@ -24,6 +29,8 @@ declare module 'vue' {
SimpleCard: typeof import('./components/SimpleCard.vue')['default']
'Tabler:databaseImport': typeof import('~icons/tabler/database-import')['default']
'Teenyicons:dockerOutline': typeof import('~icons/teenyicons/docker-outline')['default']
'VscodeIcons:fileTypeRust': typeof import('~icons/vscode-icons/file-type-rust')['default']
'VscodeIcons:fileTypeTypescriptOfficial': typeof import('~icons/vscode-icons/file-type-typescript-official')['default']
}
}

View File

@@ -24,32 +24,73 @@ pnpm run watch
+++ Ways to Consume this library
- **Search Development** - if you are updating docs, index definitions, etc. you'll run this in _watch_ mode (aka., `pnpm run start` (first time) or `pnpm run watch`)
- **Deployment** - When an _upstream_ dependency is updated this repo should be trigged by a Netlify build hook. For instance:
- `tauri` has a new release to production branch, as a `postbuild` step in Netlify build process, it will call Netlify's API and ask for a rebuild of this repo.
- we care about picking up the two AST files to build the API docs (`ts-docs.json`, `rust.json`)
- `tauri-docs` releases new docs, again a `postbuild` hook on Netlify is called to it requests a rebuild from this repo
- here we need to pickup the directly or MD files
- `NPM Dependency` - the `Models` you've defined along with all of the _types_ defined are available as an NPM dependency
- `tauri` has a new release to production branch, as a `postbuild` step in Netlify build process, it will call Netlify's API and ask for a rebuild of this repo.
- we care about picking up the two AST files to build the API docs (`ts-docs.json`, `rust.json`)
- `tauri-docs` releases new docs, again a `postbuild` hook on Netlify is called to it requests a rebuild from this repo
- here we need to pickup the directly or MD files
- **NPM Dependency** - the `Models` you've defined along with all of the _types_ defined are available as an NPM dependency
```ts
import { ProseModel } from "tauri-search";
import type { MeiliSearchResponse } from "tauri-search";
```
+++
## Models
## Sitemap
Central to using this library to build and refresh your search indexes is understanding the concept of `Model`.
- A Model has a `1:1` relationship with search indexes (or at least _potential_ indexes)
- A Model is intended to represent:
- the **document structure** that will be used for docs in the index
- allows for **configuring the index** itself (e.g., stop words, synonyms, etc.)
- allows you to embed data mappers which map from one document structure to another
+++ Take a look at the examples here to get a better bearing:
- +++ define the model:
```ts
/** structure for documents in the Prose index */
export interface IProse {
title: string; section: string;
lastUpdated: number; url: string;
}
/** structure for the input data structure */
export interface IMarkdownAst {
h1: string; h2: string; h3: string;
url: string;
}
Use the Nav bar at the top to navigation to the various sections:
/** create the Prose model */
const Prose = createModel("prose", c => c //
.synonomys({ js: ["javascript"], javascript: ["js"]})
.addMapper("markdown").mapDefn<IMarkdownAst>(i => {
title: i.h1,
section: i.h2,
lastUpdated: i.lastUpdated,
url: i.url
})
)
```
- +++ use the model to call the MeiliSearch API
```ts
import { Prose } from "./models";
// create an index
await Prose.api.createIndex();
// get index stats
await Prose.api.stats();
// search on index
await Prose.api.search("foobar");
```
- +++ leverage mappers embedded in the model
```ts
import { Prose } from "./models";
- MeiliSearch Info
- Search Bar Info
- Docker Info
Then we move into the core content types:
- Typescript API Content
- Rust API Content
- Prose Content
// map from Input structure to expected document structure
const doc: IProse = Prose.mapWith("markdown", data);
// using mapping to perform an update on the index
await Prose.updateWith("markdown", data);
```
> note: in the examples we're supposing `data` to be a single Node/Record but
> you can actually pass in either a single record or a list and it will manage
> both
## External Resources
- General Documentation

5675
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,3 @@
export enum RankingRule {
Words = "Words",
Typo = "Typo",
Proximity = "Proximity",
Attribute = "Attribute",
Sort = "Sort",
Exactness = "Exactness",
}
export enum TypescriptKind {
Project = "Project",
Namespace = "Namespace",

View File

@@ -38,3 +38,5 @@ export const githubMapper = createMapper<GithubRepoResp["data"], IRepoModel>(
url: i.html_url as url,
})
);
const g = githubMapper.map();

View File

@@ -146,42 +146,3 @@ export type PropCharacteristicsApi<E extends string> = Omit<
},
E
>;
export type SearchModel<N extends string, T extends any> = {
/** the name of the model */
name: N;
info: {
/** a unique hash code which represents it's current configuration */
hash: `${string}-${string}`;
/** all properties and their index attributes */
/** the typescript type of the document */
type: MorphType<any>;
interfaceDefn: string;
document: T;
index: {
stopWords: string[];
sortable: string[];
displayed: string[];
distinct: string[];
searchable: string[];
filterable: string[];
};
};
crud: {
/**
* Updates the search index with a given document
*/
update: (id: string, doc: T, endpoint?: string) => Promise<void>;
/**
* Adds a new document to the index
*/
add: (doc: T, endpoint?: string) => Promise<void>;
remove: (doc: T, endpoint?: string) => Promise<void>;
search: (find: string) => Promise<T[]>;
};
};

View File

@@ -3,5 +3,6 @@ export * from "./ts-ast";
export * from "./apis";
export * from "./type-guards";
export * from "./mapping";
export * from "./model";
export * from "./meiliseach";
export * from "./utility";

View File

@@ -14,7 +14,7 @@ export interface MsIndexStatsResponse {
fieldDistribution: Record<string, any>;
}
export interface MsCreateIndex {
export interface MsTaskStatus {
uid: number;
indexUid: string;
status: string;
@@ -48,3 +48,102 @@ export interface MsSettingsResponse<T extends {}> {
synonyms: Record<string, string[]>;
distinctAttribute: null | (keyof T)[] | ["*"];
}
export interface MeiliSearchHealth {
/** the current health status; is "available" when healthy */
status: string;
}
export type datetime = string;
export interface MeiliSearchInterface {
uid: string;
name: string;
/** datetime string (aka., 2022-01-23T22:47:42.745395044Z) */
createdAt: datetime;
/** datetime string (aka., 2022-01-23T22:47:42.745395044Z) */
updatedAt: datetime;
/**
* the property serving as the primary key for the index,
* use `id` unless there's a good reason not to
*/
primaryKey: string;
}
export interface MeiliSearchIndex {
numberOfDocuments: number;
isIndexing: false;
fieldDistribution: Record<string, any>;
}
/**
* Global MeiliSearch Stats
*/
export interface MeiliSearchStats {
databaseSize: number;
lastUpdate: datetime;
indexes: Record<string, MeiliSearchIndex>;
}
export type GenericDoc = { id: string; _idx?: string; [key: string]: unknown };
export interface MeiliSearchResponse<T extends {} = GenericDoc> {
hits: T[];
limit: number;
nbHits: number;
offset: number;
processingTimeMs: number;
query: string;
}
/**
* The search results but _with_ the index used inserted on
* each "hit"
*/
export type WithIndex<T extends MeiliSearchResponse> = Omit<T, "hits"> & {
hits: {
[K in keyof T["hits"]]: T["hits"][K] & Record<K, { _idx: string }>;
}[number];
};
export interface MsIndexTasks {
results: {
uid: number;
indexUid: string;
status: string;
type: string;
details: {
receiveDocuments: number;
indexedDocuments: number;
};
duration: string;
enqueuedAt: string;
startedAt: string;
finishedAt: string;
}[];
}
export interface MsVersion {
comitSha: string;
commitDate: string;
/** semver represented as string */
pkgVersion: string;
}
export interface MsKey {
description?: string;
/**
* What actions are allowed based on this key
* ```ts
* actions: ["documents.add", "documents.delete"]
* ```
*/
actions: string[];
/**
* The indexes which will give the actions permissions
*/
indexes: string[];
/** todo: check that this can be a number, it CAN be a null value */
expiresAt: number | null;
}

117
src/types/model.ts Normal file
View File

@@ -0,0 +1,117 @@
import { Mapper } from "~/utils/createMapper";
import { meiliApi } from "~/utils/model-api/meiliApi";
import { IndexSynonyms, RankingRule, RankingRulesApi } from ".";
import { ModelMapper } from "./mapping";
/**
* this represents the primary surface area for _configuring_ a search model
*/
export type SearchModelConfig<
TDoc extends {},
TMap extends MapperDictionary<string, any, TDoc> = never,
TExclude extends string = never
> = Omit<
{
searchable: (
...props: (keyof TDoc)[]
) => SearchModelConfig<TDoc, TMap, TExclude | "searchable">;
displayed: (
...props: (keyof TDoc)[]
) => SearchModelConfig<TDoc, TMap, TExclude | "displayed">;
distinct: (
...props: (keyof TDoc)[]
) => SearchModelConfig<TDoc, TMap, TExclude | "distinct">;
filterable: (
...props: (keyof TDoc)[]
) => SearchModelConfig<TDoc, TMap, TExclude | "filterable">;
sortable: (
...props: (keyof TDoc)[]
) => SearchModelConfig<TDoc, TMap, TExclude | "sortable">;
/**
* Because your website might provide content with structured English sentences, we
* recommend adding stop words. Indeed, the search-engine would not be "spoiled" by
* linking words and would focus on the main words of the query, rendering more
* accurate results.
*
* Here is the [dedicated page about stop-words](https://docs.meilisearch.com/reference/features/stop_words.html)
* in the official documentation. You can find more complete lists of
* English stop-words [like this one](https://gist.github.com/sebleier/554280).
*/
stopWords: (words: string[]) => SearchModelConfig<TDoc, TMap, TExclude | "stopWords">;
// addMapper: <TInput, TOutput>(
// m: Mapper<TInput, TOutput>
// ) => IndexApi<T, E | typeof m["name"]>;
/**
* Specify the order for the various _ranking rules_. The default ranking rules are:
*
* ```ts
* ["words", "typo", "proximity", "attribute", "sort", "exactness"]
* ```
*
* Refer to [Ranking Rules Documentation](https://docs.meilisearch.com/learn/core_concepts/relevancy.html#ranking-rules) for more info.
*/
rankingRules: (
cb: (r: RankingRulesApi) => void
) => SearchModelConfig<TDoc, TMap, TExclude | "rankingRules">;
addMapper: <N extends string>(
name: N
) => {
mapDefn: <PInput>(
mapper: ModelMapper<PInput, TDoc>
) => SearchModelConfig<TDoc, TMap & Record<N, ModelMapper<PInput, TDoc>>, TExclude>;
};
},
TExclude
>;
export interface SearchModelMeta<TDoc extends {}> {
hash?: number;
rules?: RankingRule[];
displayed?: (keyof TDoc)[];
searchable?: (keyof TDoc)[];
filterable?: (keyof TDoc)[];
distinct?: (keyof TDoc)[];
sortable?: (keyof TDoc)[];
stopWords?: string[];
synonyms?: IndexSynonyms;
}
export type MapperDictionary<K extends string, I extends {}, O extends {}> = Record<
K,
Mapper<I, O>
>;
/**
* An encapsulation of the "state" which a model is managing.
*/
export type SearchModelState<
/** document structure */
TDoc extends {},
/** mapper configuration */
TMap extends MapperDictionary<string, any, TDoc> | never = never,
TExclude extends string = never
> = {
/** the name of the model */
name: Readonly<string>;
/**
* the TS type of the document being managed by this Model
* ```ts
* type Doc = typeof state.type;
* ```
*/
type: TDoc;
/**
* The index configuration which this Model is managing
*/
index: SearchModelMeta<TDoc>;
/**
* A dictionary of mapping function which convert an expected data structure to
* this Model's document structure.
*/
mappers: TMap;
};
export type MeiliApi = ReturnType<typeof meiliApi>;

124
src/utils/MeiliSearchApi.ts Normal file
View File

@@ -0,0 +1,124 @@
import { env } from "process";
import fetch, { RequestInit } from "node-fetch";
import {
MeiliSearchHealth,
MeiliSearchIndex,
MeiliSearchResponse,
MeiliSearchStats,
MsVersion,
MsAddOrReplace,
MsIndexTasks,
MsSettingsResponse,
MsTaskStatus,
MsKey,
} from "~/types";
import { slice } from "cheerio/lib/api/traversing";
export interface MeiliSearchOptions {
url?: string;
}
export type PagingOptions = {
limit?: number;
offset?: number;
};
export type ApiOptions = Omit<RequestInit, "method">;
export function MeiliSearchApi<TDoc extends {}>(
idx: string,
options: MeiliSearchOptions = {}
) {
const baseUrl = options.url || env.URL || "http://localhost:7700";
const call = async <T>(
method: "GET" | "POST" | "PUT" | "DELETE",
url: string,
options: ApiOptions = {}
): Promise<T> => {
const res = await fetch(url, { ...options, method });
if (res.ok) {
return res.json() as Promise<T>;
} else {
throw new Error(
`Problem calling the MeiliSearch API at the path of: ${method} ${url}. Status message was "${res.statusText}" [${res.status}]`
);
}
};
const get = <T>(url: string, options: ApiOptions = {}) => {
return call("GET", `${baseUrl}/${url.startsWith("/" ? url.slice(1) : url)}`, options);
};
const put = <T>(
url: string,
body?: ApiOptions["body"],
options: Omit<ApiOptions, "body"> = {}
) => {
return call<T>("PUT", `${baseUrl}/${url.startsWith("/" ? url.slice(1) : url)}`, {
...options,
body,
});
};
const post = async <T>(
url: string,
body?: ApiOptions["body"],
options: Omit<ApiOptions, "body"> = {}
): Promise<T> => {
return call<T>("POST", `${baseUrl}/${url.startsWith("/" ? url.slice(1) : url)}`, {
...options,
body,
});
};
const del = <T>(url: string, options: ApiOptions = {}) => {
return call<T>(
"DELETE",
`${baseUrl}/${url.startsWith("/" ? url.slice(1) : url)}`,
options
);
};
const endpoints = {
// per index
getIndexTasks: get<MsIndexTasks>(`indexes/${idx}/tasks`),
getDocument: (docId: string) => get<TDoc>(`indexes/${idx}/documents/${docId}`),
deleteDocument: (docId: string) =>
del<MsTaskStatus>(`indexes/${idx}/documents/${docId}`),
getDocuments: (paging: PagingOptions = {}) => get<TDoc[]>(`indexes/${idx}/documents`),
deleteAllDocuments: del<MsTaskStatus>(`indexes/${idx}/documents`),
addOrReplaceDocuments: (doc: TDoc, o: ApiOptions = {}) =>
post<MsAddOrReplace>(`indexes/${idx}/documents`, JSON.stringify(doc), o),
addOrUpdateDocuments: (doc: TDoc, o: ApiOptions = {}) =>
put<MsAddOrReplace>(`indexes/${idx}/documents`, JSON.stringify(doc), o),
search: (text: string) => get<MeiliSearchResponse>(`indexes/${idx}/search?q=${text}`),
getAllIndexSettings: get<MsSettingsResponse<TDoc>>(`indexes/${idx}/settings`),
updateIndexSettings: (settings: MsSettingsResponse<TDoc>) =>
post<MsTaskStatus>(`indexes/${idx}/settings`, JSON.stringify(settings)),
resetIndexSettings: del<MsTaskStatus>(`indexes/${idx}/settings`),
updateRankingRules: post<MsTaskStatus>(`indexes/${idx}/settings/ranking-rules`),
updateDistinctAttribute: post<MsTaskStatus>(
`indexes/${idx}/settings/distinct-attribute`
),
updateSearchableAttributes: post<MsTaskStatus>(
`indexes/${idx}/settings/searchable-attributes`
),
updateSortableAttributes: post<MsTaskStatus>(
`indexes/${idx}/settings/sortable-attributes`
),
updateDisplayedAttributes: post<MsTaskStatus>(
`indexes/${idx}/settings/displayed-attributes`
),
updateSynonyms: post<MsTaskStatus>(`indexes/${idx}/settings/synonyms`),
updateStopWords: post<MsTaskStatus>(`indexes/${idx}/settings/stop-words`),
// cross-index
stats: get<MeiliSearchStats>(`stats`),
health: get<MeiliSearchHealth>(`health`),
indexes: get<MeiliSearchIndex>(`indexes`),
version: get<MsVersion>(`version`),
getKeys: get<MsKey[]>(`keys`),
createKey: (key: MsKey) => post<MsTaskStatus>(`keys`, JSON.stringify(key)),
deleteKey: (key: string) => del<MsTaskStatus>(`keys/${key}`),
};
return { get, put, post, delete: del, endpoints };
}

View File

@@ -1,207 +1,37 @@
import {
IndexSynonyms,
MsAddOrReplace,
MsCreateIndex,
MsIndexStatsResponse,
MsIndexStatusResponse,
MsSearchResponse,
MsSettingsResponse,
RankingRule,
RankingRulesApi,
MapperDictionary,
SearchModelState,
SearchModel,
SearchModelConfig,
} from "~/types";
import xxhash from "xxhash-wasm";
import fetch from "node-fetch";
import { getUrl } from "./getUrl";
import { rankingRules } from "./model-api/rankingRules";
export type IndexApi<T, E extends string = never> = Omit<
{
searchable: (...props: (keyof T)[]) => IndexApi<T, E | "searchable">;
displayed: (...props: (keyof T)[]) => IndexApi<T, E | "displayed">;
distinct: (...props: (keyof T)[]) => IndexApi<T, E | "distinct">;
filterable: (...props: (keyof T)[]) => IndexApi<T, E | "filterable">;
sortable: (...props: (keyof T)[]) => IndexApi<T, E | "sortable">;
/**
* Because your website might provide content with structured English sentences, we
* recommend adding stop words. Indeed, the search-engine would not be "spoiled" by
* linking words and would focus on the main words of the query, rendering more
* accurate results.
*
* Here is the [dedicated page about stop-words](https://docs.meilisearch.com/reference/features/stop_words.html)
* in the official documentation. You can find more complete lists of
* English stop-words [like this one](https://gist.github.com/sebleier/554280).
*/
stopWords: (words: string[]) => IndexApi<T, E | "stopWords">;
import { meiliApi } from "./model-api/meiliApi";
import { mappingApi } from "./model-api/mappingApi";
import { modelConfigApi } from "./model-api/modelConfig";
// addMapper: <TInput, TOutput>(
// m: Mapper<TInput, TOutput>
// ) => IndexApi<T, E | typeof m["name"]>;
/**
* Specify the order for the various _ranking rules_. The default ranking rules are:
*
* ```ts
* ["words", "typo", "proximity", "attribute", "sort", "exactness"]
* ```
*
* Refer to [Ranking Rules Documentation](https://docs.meilisearch.com/learn/core_concepts/relevancy.html#ranking-rules) for more info.
*/
rankingRules: (cb: (r: RankingRulesApi) => void) => IndexApi<T, E | "rankingRules">;
},
E
>;
const modelConfigApi = <T extends {}>(update: (s: PartialModel<T>) => void) => {
const api = <E extends string = never, M extends string = never>(): IndexApi<T, E> =>
({
searchable(...props) {
if (props?.length > 0) {
update({ info: { searchable: props } });
}
return api<E | "searchable", M>();
},
displayed(...props) {
if (props?.length > 0) {
update({ info: { displayed: props } });
}
return api<E | "displayed", M>();
},
distinct(...props) {
if (props?.length > 0) {
update({ info: { distinct: props } });
}
return api<E | "distinct", M>();
},
filterable(...props) {
if (props?.length > 0) {
update({ info: { filterable: props } });
}
return api<E | "filterable", M>();
},
sortable(...props) {
if (props?.length > 0) {
update({ info: { sortable: props } });
}
return api<E | "searchable", M>();
},
stopWords(words) {
update({ info: { stopWords: words } });
return api<E | "stopWords">();
},
rankingRules(cb: (r: RankingRulesApi) => void) {
const updateRules = (r: RankingRule[]) => {
update({ info: { rules: r } });
};
const ruleApi = rankingRules(updateRules);
cb(ruleApi);
return api<E | "rankingRules">();
},
// addMapper<TInput extends {}, TOutput extends {}>(
// mapper: Mapper<TInput, TOutput>
// ) {
// update({ mappers: })
// return api<E | typeof mapper["name"]>
// },
} as IndexApi<T, E>);
return api();
};
export type SearchModel<T extends {}> = {
name: string;
hash: number;
info: {
type: T;
rules?: RankingRule[];
displayed?: (keyof T)[];
searchable?: (keyof T)[];
filterable?: (keyof T)[];
distinct?: (keyof T)[];
sortable?: (keyof T)[];
stopWords?: string[];
synonyms?: IndexSynonyms;
const finalize =
(name: string, initial: ) =>
<TDoc extends {}, TMap extends MapperDictionary<string, any, TDoc>>(
state: SearchModelState<TDoc, TMap>
) => {
return {
...state,
api: meiliApi<TDoc>(state.name),
...mappingApi<TDoc, TMap>(name, state.mappers),
} as SearchModel<TDoc, TMap>;
};
query: {
createIndex(): Promise<MsCreateIndex>;
showIndex(): Promise<MsIndexStatusResponse>;
showIndexStats(): Promise<MsIndexStatsResponse>;
addOrReplaceDoc(doc: T): Promise<MsAddOrReplace>;
search(find: string): Promise<MsSearchResponse<T>>;
getAllSettings(): Promise<MsSettingsResponse<T>>;
};
toString(): string;
};
export type PartialModel<T extends {}> = Omit<Partial<SearchModel<T>>, "info"> & {
info: Partial<SearchModel<T>["info"]>;
};
export const createModel = async <T extends Record<string, any>>(
export const createModel = <TDoc extends {}>(
name: string,
cb?: (api: IndexApi<T>) => void
cb?: (api: SearchModelConfig<TDoc, never, never>) => void
) => {
const { h32 } = await xxhash();
let state: SearchModel<T> = {
name,
hash: h32("name"),
info: {
type: {} as unknown as T,
},
query: {
createIndex: async () => {
return (await (
await fetch(getUrl(`indexes/${name}`), {
method: "POST",
body: JSON.stringify({ uid: name, primaryKey: "id" }),
})
).json()) as MsCreateIndex;
},
showIndex: async () => {
return (await (
await fetch(getUrl(`indexes/${name}`))
).json()) as MsIndexStatusResponse;
},
showIndexStats: async () => {
return (await (
await fetch(getUrl(`indexes/${name}/stats`))
).json()) as MsIndexStatsResponse;
},
addOrReplaceDoc: async (doc: T) => {
return (await (
await fetch(getUrl(`indexes/${name}/documents`), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(doc),
})
).json()) as MsAddOrReplace;
},
getAllSettings: async () => {
return (await (
await fetch(getUrl(`indexes/${name}`))
).json()) as MsSettingsResponse<T>;
},
search: async (find: string) => {
return (await (
await fetch(getUrl(`indexes/${name}/search?q=${find}`))
).json()) as MsSearchResponse<T>;
},
},
};
const updateState = (s: PartialModel<T>) => {
if (s.info) {
state.info = { ...state.info, ...s.info };
state.hash = h32(JSON.stringify({ ...state.info, name: state.name }));
}
};
const f = finalize(name, { state: { name } });
if (cb) {
cb(modelConfigApi<T>(updateState));
const api = modelConfigApi<TDoc>(name, f);
cb(api);
} else {
// default config
}
return {
@@ -209,5 +39,5 @@ export const createModel = async <T extends Record<string, any>>(
toString() {
return `Model(${name}::${state.hash})`;
},
} as SearchModel<T>;
} as SearchModel<TDoc, TMap>;
};

View File

@@ -1,13 +0,0 @@
import { env } from "process";
export function getUrl(offset: string = "") {
const host = env.HOST || "localhost";
const port = env.PORT || "7700";
const protocol =
env.PROTO || host === "localhost" || host.startsWith("192.") ? "http://" : "https://";
const url = env.URL ? env.URL : `${protocol}${host}:${port}/`;
if (offset.startsWith("/")) {
offset = offset.slice(1);
}
return url.endsWith("/") ? `${url}${offset}` : `${url}/${offset}`;
}

View File

@@ -0,0 +1,25 @@
import { MapperDictionary, MsAddOrReplace } from "~/types";
import { MeiliSearchApi } from "../MeiliSearchApi";
export function mappingApi<
TDoc extends {},
TMap extends MapperDictionary<string, any, TDoc>
>(idx: string, mappings: TMap) {
return {
mapWith: <K extends keyof TMap>(
map: K,
data: Parameters<TMap[K]["map"]>[0]
): TDoc => {
return mappings[map].map(data) as TDoc;
},
updateWith: async <K extends keyof TMap, D extends Parameters<TMap[K]["map"]>[0]>(
map: K,
data: D
): Promise<MsAddOrReplace> => {
const { endpoints } = MeiliSearchApi(idx);
const doc = mappings[map].map(data);
return endpoints.addOrReplaceDocuments(doc);
},
};
}

View File

@@ -0,0 +1,7 @@
import { MeiliSearchApi } from "../MeiliSearchApi";
/** adds implementation for the API endpoints of MeiliSearch */
export function meiliApi<TDoc extends {}>(idx: string) {
const { endpoints } = MeiliSearchApi(idx);
return endpoints;
}

View File

@@ -0,0 +1,126 @@
import {
MapperDictionary,
ModelMapper,
RankingRule,
RankingRulesApi,
SearchModelConfig,
SearchModelState,
} from "~/types";
import { rankingRules } from "./rankingRules";
/**
* Higher order fn for managing configuration of a Model.
*
* In this first call you just pass in the `TDoc` generic
* along with the model name. This will return a Tuple
* with the config API and a function to call for results.
*/
export const modelConfigApi = <TDoc extends {}>(
/** the name of the index */
name: string,
/**
* A callback function which allows the configurator to call during configuration
* process so that `createModel()` can pull out the final configuration.
*/
finalize: <F extends SearchModelState<TDoc, MapperDictionary<string, {}, TDoc>>>(
state: F
) => void
) => {
const api = <
TMap extends MapperDictionary<string, any, TDoc> = never,
TExclude extends string = never
>(
state: SearchModelState<TDoc, TMap, TExclude>
): SearchModelConfig<TDoc, TMap, TExclude> =>
({
searchable(...props: (keyof TDoc)[]) {
return api<TMap, TExclude | "searchable">({
...state,
index: {
...state.index,
searchable: props,
},
});
},
displayed(...props: (keyof TDoc)[]) {
return api<TMap, TExclude | "displayed">({
...state,
index: {
...state.index,
displayed: props,
},
});
},
distinct(...props: (keyof TDoc)[]) {
return api<TMap, TExclude | "distinct">({
...state,
index: {
...state.index,
distinct: props,
},
});
},
filterable(...props: (keyof TDoc)[]) {
return api<TMap, TExclude | "filterable">({
...state,
index: {
...state.index,
filterable: props,
},
});
},
sortable(...props: (keyof TDoc)[]) {
return api<TMap, TExclude | "sortable">({
...state,
index: {
...state.index,
sortable: props,
},
});
},
stopWords(words: string[]) {
return api<TMap, TExclude | "sortable">({
...state,
index: {
...state.index,
stopWords: words,
},
});
},
rankingRules(cb: (r: RankingRulesApi) => void) {
let r: RankingRule[] = [];
const update = (rules: RankingRule[]) => {
r = rules;
};
const ruleApi = rankingRules(update);
cb(ruleApi);
return api<TMap, TExclude | "sortable">({
...state,
index: {
...state.index,
rank: r,
},
});
},
addMapper<N extends string>(mapName: N) {
return {
mapDefn: <TInput extends {}>(mapper: ModelMapper<TInput, TDoc>) => {
return api<TMap & Record<N, ModelMapper<TInput, TDoc>>, TExclude>({
...state,
mappers: { ...state.mappers, [mapName]: mapper },
});
},
};
},
} as unknown as SearchModelConfig<TDoc, TMap, TExclude>);
const response = {
finalize,
api,
};
return api();
};

View File

@@ -1,31 +1,52 @@
import { createModel } from "~/utils/createModel";
type Model = {
title: string;
section: string;
lastUpdated: number;
};
type AstModel = {
h1: string;
h2: string;
lastUpdated: string;
};
describe("createModel()", () => {
it("Configuring index properties on a model is seen in model's info section", async () => {
const m = await createModel<Model>("foobar", (m) =>
const m = createModel<Model>("foobar", (m) =>
m //
.sortable("section")
.displayed("title", "section")
.filterable("lastUpdated")
);
expect(m.info.displayed).toContain("title");
expect(m.info.displayed).toContain("section");
expect(m.info.displayed).not.toContain("lastUpdated");
expect(m.meta.displayed).toContain("title");
expect(m.meta.displayed).toContain("section");
expect(m.meta.displayed).not.toContain("lastUpdated");
expect(m.info.sortable).toContain("section");
expect(m.info.filterable).toContain("lastUpdated");
expect(m.meta.sortable).toContain("section");
expect(m.meta.filterable).toContain("lastUpdated");
});
it("toString() produces a clear and concise indication of what model it is", async () => {
const m = await createModel<Model>("foobar");
const m = createModel<Model>("foobar");
expect(m.toString()).toContain("Model");
expect(m.toString()).toContain("foobar::");
expect(m.toString()).toContain(String(m.hash));
expect(m.toString()).toContain(String(m.meta.hash));
});
it("adding mappers to a model, exposes this on the model API", () => {
// define a mapper on `m`
const m = createModel<Model>("foobar", (c) =>
c //
.addMapper("doc")
.mapDefn<AstModel>((i) => ({
title: i.h1,
section: i.h2,
lastUpdated: new Date(i.lastUpdated).getTime(),
}))
.searchable("title", "section")
);
});
});

View File

@@ -2,11 +2,12 @@ import { readFileSync } from "fs";
import matter from "gray-matter";
describe("frontmatter tests", () => {
it("faq", () => {
const content = readFileSync("test/fixtures/prose/faq.md", "utf-8");
const content = readFileSync(
"test/fixtures/prose/guides/contributor-guide.md",
"utf-8"
);
const output = matter(content);
console.log(output)
console.log(output);
});
});