@porulle/adapter-meilisearch 0.10.8 → 0.13.0

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.
@@ -16,6 +16,7 @@ interface MeiliIndexLike {
16
16
  facetDistribution?: Record<string, Record<string, number>>;
17
17
  }>;
18
18
  updateFilterableAttributes(attributes: string[]): Promise<unknown>;
19
+ getFilterableAttributes?(): Promise<unknown>;
19
20
  }
20
21
  interface MeiliClientLike {
21
22
  index(uid: string): MeiliIndexLike;
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAwB,KAAK,aAAa,EAAE,KAAK,cAAc,EAA4E,MAAM,eAAe,CAAC;AAExK,UAAU,cAAc;IACtB,YAAY,CAAC,SAAS,EAAE,cAAc,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5D,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACjD,MAAM,CACJ,KAAK,EAAE,MAAM,EACb,OAAO,CAAC,EAAE;QACR,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;QAC3B,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;QAClB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;KACjC,GACA,OAAO,CAAC;QACT,IAAI,EAAE,KAAK,CAAC,cAAc,GAAG;YAAE,aAAa,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;QACzD,kBAAkB,CAAC,EAAE,MAAM,CAAC;QAC5B,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;KAC5D,CAAC,CAAC;IACH,0BAA0B,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACnE,uBAAuB,CAAC,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;CAC9C;AAED,UAAU,eAAe;IACvB,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,CAAC;CACpC;AAED,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;IAChC,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B;AAoFD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,yBAAyB,GAAG,aAAa,CAyHpF"}
@@ -1,7 +1,11 @@
1
1
  import { MeiliSearch } from "meilisearch";
2
2
  import { Err, Ok } from "@porulle/core";
3
+ const SAFE_ATTRIBUTE_NAME = /^[A-Za-z0-9_-]+$/;
3
4
  function toFilter(params) {
4
5
  const filters = [];
6
+ if (params.filters?.organizationId) {
7
+ filters.push(`organizationId = ${JSON.stringify(params.filters.organizationId)}`);
8
+ }
5
9
  if (params.filters?.type) {
6
10
  filters.push(`type = \"${params.filters.type}\"`);
7
11
  }
@@ -14,8 +18,33 @@ function toFilter(params) {
14
18
  if (params.filters?.brand) {
15
19
  filters.push(`brands = \"${params.filters.brand}\"`);
16
20
  }
21
+ for (const [name, requested] of Object.entries(params.filters?.attributes ?? {})) {
22
+ const values = Array.isArray(requested) ? requested : [requested];
23
+ if (values.length === 0 || !SAFE_ATTRIBUTE_NAME.test(name)) {
24
+ filters.push('id = ""');
25
+ continue;
26
+ }
27
+ const expressions = values.map((value) => `attributes.${name} = ${JSON.stringify(value)}`);
28
+ if (expressions.length > 1) {
29
+ filters.push(`(${expressions.join(" OR ")})`);
30
+ }
31
+ else if (expressions.length === 1) {
32
+ filters.push(expressions[0]);
33
+ }
34
+ }
17
35
  return filters;
18
36
  }
37
+ function normalizeFacets(facets) {
38
+ return Object.fromEntries(Object.entries(facets).map(([name, values]) => [
39
+ name.startsWith("attributes.") ? name.slice("attributes.".length) : name,
40
+ values,
41
+ ]));
42
+ }
43
+ function toFacetName(name) {
44
+ if (["type", "status", "category", "categories", "brand", "brands"].includes(name))
45
+ return name;
46
+ return name.startsWith("attributes.") ? name : `attributes.${name}`;
47
+ }
19
48
  function normalizeSearchResult(params, result) {
20
49
  const page = params.page ?? 1;
21
50
  const limit = params.limit ?? 20;
@@ -28,19 +57,37 @@ function normalizeSearchResult(params, result) {
28
57
  total: result.estimatedTotalHits ?? result.hits.length,
29
58
  page,
30
59
  limit,
31
- facets: result.facetDistribution ?? {},
60
+ facets: normalizeFacets(result.facetDistribution ?? {}),
32
61
  };
33
62
  }
34
63
  export function meilisearchAdapter(options) {
35
64
  const client = options.client ?? new MeiliSearch({ host: options.host, ...(options.apiKey ? { apiKey: options.apiKey } : {}) });
36
65
  const indexName = options.indexName ?? "catalog";
37
- const filterable = options.filterableAttributes ?? ["type", "status", "categories", "brands"];
38
- let filterableConfigured = false;
39
- async function ensureFilterable(index) {
40
- if (filterableConfigured)
66
+ const filterable = options.filterableAttributes ?? ["organizationId", "type", "status", "categories", "brands"];
67
+ const attributeNames = new Set();
68
+ let configuredFilterable;
69
+ async function ensureFilterable(index, documents) {
70
+ for (const document of documents) {
71
+ for (const name of Object.keys(document.attributes ?? {})) {
72
+ if (SAFE_ATTRIBUTE_NAME.test(name))
73
+ attributeNames.add(name);
74
+ }
75
+ }
76
+ if (configuredFilterable === undefined && index.getFilterableAttributes) {
77
+ const stored = await index.getFilterableAttributes();
78
+ configuredFilterable = Array.isArray(stored)
79
+ ? stored.filter((name) => typeof name === "string")
80
+ : [];
81
+ }
82
+ const nextFilterable = [
83
+ ...(configuredFilterable ?? []),
84
+ ...filterable,
85
+ ...[...attributeNames].map((name) => `attributes.${name}`),
86
+ ].filter((name, position, list) => list.indexOf(name) === position);
87
+ if (configuredFilterable?.length === nextFilterable.length && configuredFilterable.every((name, index) => name === nextFilterable[index]))
41
88
  return;
42
- await index.updateFilterableAttributes(filterable);
43
- filterableConfigured = true;
89
+ await index.updateFilterableAttributes(nextFilterable);
90
+ configuredFilterable = nextFilterable;
44
91
  }
45
92
  return {
46
93
  providerId: "meilisearch",
@@ -49,7 +96,7 @@ export function meilisearchAdapter(options) {
49
96
  if (documents.length === 0)
50
97
  return Ok(undefined);
51
98
  const index = client.index(indexName);
52
- await ensureFilterable(index);
99
+ await ensureFilterable(index, documents);
53
100
  await index.addDocuments(documents);
54
101
  return Ok(undefined);
55
102
  }
@@ -82,9 +129,11 @@ export function meilisearchAdapter(options) {
82
129
  const offset = (page - 1) * limit;
83
130
  const filters = toFilter(params);
84
131
  const index = client.index(indexName);
132
+ const requestedFacets = (params.facets ?? ["type", "status", "categories", "brands"]).map(toFacetName);
133
+ const facets = requestedFacets.filter((name, position, list) => list.indexOf(name) === position);
85
134
  const result = await index.search(params.query, {
86
135
  ...(filters.length > 0 ? { filter: filters } : {}),
87
- facets: params.facets ?? ["type", "status", "categories", "brands"],
136
+ facets,
88
137
  limit,
89
138
  offset,
90
139
  });
@@ -100,8 +149,14 @@ export function meilisearchAdapter(options) {
100
149
  async suggest(params) {
101
150
  try {
102
151
  const index = client.index(indexName);
152
+ const suggestFilters = [
153
+ ...(params.organizationId
154
+ ? [`organizationId = ${JSON.stringify(params.organizationId)}`]
155
+ : []),
156
+ ...(params.type ? [`type = \"${params.type}\"`] : []),
157
+ ];
103
158
  const result = await index.search(params.prefix, {
104
- ...(params.type ? { filter: [`type = \"${params.type}\"`] } : {}),
159
+ ...(suggestFilters.length > 0 ? { filter: suggestFilters } : {}),
105
160
  attributesToRetrieve: ["title"],
106
161
  limit: params.limit ?? 10,
107
162
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/adapter-meilisearch",
3
- "version": "0.10.8",
3
+ "version": "0.13.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "meilisearch": "^0.54.0",
15
- "@porulle/core": "0.10.8"
15
+ "@porulle/core": "0.13.0"
16
16
  },
17
17
  "devDependencies": {
18
18
  "@types/node": "^24.5.2",
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ interface MeiliIndexLike {
19
19
  facetDistribution?: Record<string, Record<string, number>>;
20
20
  }>;
21
21
  updateFilterableAttributes(attributes: string[]): Promise<unknown>;
22
+ getFilterableAttributes?(): Promise<unknown>;
22
23
  }
23
24
 
24
25
  interface MeiliClientLike {
@@ -33,9 +34,15 @@ export interface MeilisearchAdapterOptions {
33
34
  client?: MeiliClientLike;
34
35
  }
35
36
 
37
+ const SAFE_ATTRIBUTE_NAME = /^[A-Za-z0-9_-]+$/;
38
+
36
39
  function toFilter(params: SearchQueryParams): string[] {
37
40
  const filters: string[] = [];
38
41
 
42
+ if (params.filters?.organizationId) {
43
+ filters.push(`organizationId = ${JSON.stringify(params.filters.organizationId)}`);
44
+ }
45
+
39
46
  if (params.filters?.type) {
40
47
  filters.push(`type = \"${params.filters.type}\"`);
41
48
  }
@@ -52,9 +59,39 @@ function toFilter(params: SearchQueryParams): string[] {
52
59
  filters.push(`brands = \"${params.filters.brand}\"`);
53
60
  }
54
61
 
62
+ for (const [name, requested] of Object.entries(params.filters?.attributes ?? {})) {
63
+ const values = Array.isArray(requested) ? requested : [requested];
64
+ if (values.length === 0 || !SAFE_ATTRIBUTE_NAME.test(name)) {
65
+ filters.push('id = ""');
66
+ continue;
67
+ }
68
+ const expressions = values.map((value) => `attributes.${name} = ${JSON.stringify(value)}`);
69
+ if (expressions.length > 1) {
70
+ filters.push(`(${expressions.join(" OR ")})`);
71
+ } else if (expressions.length === 1) {
72
+ filters.push(expressions[0]!);
73
+ }
74
+ }
75
+
55
76
  return filters;
56
77
  }
57
78
 
79
+ function normalizeFacets(
80
+ facets: Record<string, Record<string, number>>,
81
+ ): Record<string, Record<string, number>> {
82
+ return Object.fromEntries(
83
+ Object.entries(facets).map(([name, values]) => [
84
+ name.startsWith("attributes.") ? name.slice("attributes.".length) : name,
85
+ values,
86
+ ]),
87
+ );
88
+ }
89
+
90
+ function toFacetName(name: string): string {
91
+ if (["type", "status", "category", "categories", "brand", "brands"].includes(name)) return name;
92
+ return name.startsWith("attributes.") ? name : `attributes.${name}`;
93
+ }
94
+
58
95
  function normalizeSearchResult(
59
96
  params: SearchQueryParams,
60
97
  result: {
@@ -75,7 +112,7 @@ function normalizeSearchResult(
75
112
  total: result.estimatedTotalHits ?? result.hits.length,
76
113
  page,
77
114
  limit,
78
- facets: result.facetDistribution ?? {},
115
+ facets: normalizeFacets(result.facetDistribution ?? {}),
79
116
  };
80
117
  }
81
118
 
@@ -83,14 +120,31 @@ export function meilisearchAdapter(options: MeilisearchAdapterOptions): SearchAd
83
120
  const client: MeiliClientLike =
84
121
  options.client ?? new MeiliSearch({ host: options.host, ...(options.apiKey ? { apiKey: options.apiKey } : {}) });
85
122
  const indexName = options.indexName ?? "catalog";
86
- const filterable = options.filterableAttributes ?? ["type", "status", "categories", "brands"];
123
+ const filterable = options.filterableAttributes ?? ["organizationId", "type", "status", "categories", "brands"];
87
124
 
88
- let filterableConfigured = false;
125
+ const attributeNames = new Set<string>();
126
+ let configuredFilterable: string[] | undefined;
89
127
 
90
- async function ensureFilterable(index: MeiliIndexLike): Promise<void> {
91
- if (filterableConfigured) return;
92
- await index.updateFilterableAttributes(filterable);
93
- filterableConfigured = true;
128
+ async function ensureFilterable(index: MeiliIndexLike, documents: SearchDocument[]): Promise<void> {
129
+ for (const document of documents) {
130
+ for (const name of Object.keys(document.attributes ?? {})) {
131
+ if (SAFE_ATTRIBUTE_NAME.test(name)) attributeNames.add(name);
132
+ }
133
+ }
134
+ if (configuredFilterable === undefined && index.getFilterableAttributes) {
135
+ const stored = await index.getFilterableAttributes();
136
+ configuredFilterable = Array.isArray(stored)
137
+ ? stored.filter((name): name is string => typeof name === "string")
138
+ : [];
139
+ }
140
+ const nextFilterable = [
141
+ ...(configuredFilterable ?? []),
142
+ ...filterable,
143
+ ...[...attributeNames].map((name) => `attributes.${name}`),
144
+ ].filter((name, position, list) => list.indexOf(name) === position);
145
+ if (configuredFilterable?.length === nextFilterable.length && configuredFilterable.every((name, index) => name === nextFilterable[index])) return;
146
+ await index.updateFilterableAttributes(nextFilterable);
147
+ configuredFilterable = nextFilterable;
94
148
  }
95
149
 
96
150
  return {
@@ -100,7 +154,7 @@ export function meilisearchAdapter(options: MeilisearchAdapterOptions): SearchAd
100
154
  try {
101
155
  if (documents.length === 0) return Ok(undefined);
102
156
  const index = client.index(indexName);
103
- await ensureFilterable(index);
157
+ await ensureFilterable(index, documents);
104
158
  await index.addDocuments(documents);
105
159
  return Ok(undefined);
106
160
  } catch (error) {
@@ -133,9 +187,11 @@ export function meilisearchAdapter(options: MeilisearchAdapterOptions): SearchAd
133
187
  const filters = toFilter(params);
134
188
 
135
189
  const index = client.index(indexName);
190
+ const requestedFacets = (params.facets ?? ["type", "status", "categories", "brands"]).map(toFacetName);
191
+ const facets = requestedFacets.filter((name, position, list) => list.indexOf(name) === position);
136
192
  const result = await index.search(params.query, {
137
193
  ...(filters.length > 0 ? { filter: filters } : {}),
138
- facets: params.facets ?? ["type", "status", "categories", "brands"],
194
+ facets,
139
195
  limit,
140
196
  offset,
141
197
  });
@@ -152,8 +208,14 @@ export function meilisearchAdapter(options: MeilisearchAdapterOptions): SearchAd
152
208
  async suggest(params: SearchSuggestParams): Promise<Result<string[]>> {
153
209
  try {
154
210
  const index = client.index(indexName);
211
+ const suggestFilters = [
212
+ ...(params.organizationId
213
+ ? [`organizationId = ${JSON.stringify(params.organizationId)}`]
214
+ : []),
215
+ ...(params.type ? [`type = \"${params.type}\"`] : []),
216
+ ];
155
217
  const result = await index.search(params.prefix, {
156
- ...(params.type ? { filter: [`type = \"${params.type}\"`] } : {}),
218
+ ...(suggestFilters.length > 0 ? { filter: suggestFilters } : {}),
157
219
  attributesToRetrieve: ["title"],
158
220
  limit: params.limit ?? 10,
159
221
  });
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAwB,KAAK,aAAa,EAAE,KAAK,cAAc,EAA4E,MAAM,eAAe,CAAC;AAExK,UAAU,cAAc;IACtB,YAAY,CAAC,SAAS,EAAE,cAAc,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5D,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACjD,MAAM,CACJ,KAAK,EAAE,MAAM,EACb,OAAO,CAAC,EAAE;QACR,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;QAC3B,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;QAClB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;KACjC,GACA,OAAO,CAAC;QACT,IAAI,EAAE,KAAK,CAAC,cAAc,GAAG;YAAE,aAAa,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;QACzD,kBAAkB,CAAC,EAAE,MAAM,CAAC;QAC5B,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;KAC5D,CAAC,CAAC;IACH,0BAA0B,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACpE;AAED,UAAU,eAAe;IACvB,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,CAAC;CACpC;AAED,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;IAChC,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B;AAgDD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,yBAAyB,GAAG,aAAa,CAgGpF"}