@tsctl/cli 0.2.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.
- package/README.md +735 -0
- package/bin/tsctl.js +2 -0
- package/package.json +65 -0
- package/src/__tests__/analyticsrules.test.ts +303 -0
- package/src/__tests__/apikeys.test.ts +223 -0
- package/src/__tests__/apply.test.ts +245 -0
- package/src/__tests__/client.test.ts +48 -0
- package/src/__tests__/collection-advanced.test.ts +274 -0
- package/src/__tests__/config-loader.test.ts +217 -0
- package/src/__tests__/curationsets.test.ts +190 -0
- package/src/__tests__/helpers.ts +17 -0
- package/src/__tests__/import-drift.test.ts +231 -0
- package/src/__tests__/migrate-advanced.test.ts +197 -0
- package/src/__tests__/migrate.test.ts +220 -0
- package/src/__tests__/plan-new-resources.test.ts +258 -0
- package/src/__tests__/plan.test.ts +337 -0
- package/src/__tests__/presets.test.ts +97 -0
- package/src/__tests__/resources.test.ts +592 -0
- package/src/__tests__/setup.ts +77 -0
- package/src/__tests__/state.test.ts +312 -0
- package/src/__tests__/stemmingdictionaries.test.ts +111 -0
- package/src/__tests__/stopwords.test.ts +109 -0
- package/src/__tests__/synonymsets.test.ts +170 -0
- package/src/__tests__/types.test.ts +416 -0
- package/src/apply/index.ts +336 -0
- package/src/cli/index.ts +1106 -0
- package/src/client/index.ts +55 -0
- package/src/config/loader.ts +158 -0
- package/src/index.ts +45 -0
- package/src/migrate/index.ts +220 -0
- package/src/plan/index.ts +1333 -0
- package/src/resources/alias.ts +59 -0
- package/src/resources/analyticsrule.ts +134 -0
- package/src/resources/apikey.ts +203 -0
- package/src/resources/collection.ts +424 -0
- package/src/resources/curationset.ts +155 -0
- package/src/resources/index.ts +11 -0
- package/src/resources/override.ts +174 -0
- package/src/resources/preset.ts +83 -0
- package/src/resources/stemmingdictionary.ts +103 -0
- package/src/resources/stopword.ts +100 -0
- package/src/resources/synonym.ts +152 -0
- package/src/resources/synonymset.ts +144 -0
- package/src/state/index.ts +206 -0
- package/src/types/index.ts +451 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import type { CollectionCreateSchema } from "typesense/lib/Typesense/Collections.js";
|
|
2
|
+
import { getClient } from "../client/index.js";
|
|
3
|
+
import type { CollectionConfig, Field } from "../types/index.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convert our field config to Typesense field schema
|
|
7
|
+
*/
|
|
8
|
+
function toTypesenseField(field: Field): Record<string, unknown> {
|
|
9
|
+
const result: Record<string, unknown> = {
|
|
10
|
+
name: field.name,
|
|
11
|
+
type: field.type,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
if (field.optional !== undefined) result["optional"] = field.optional;
|
|
15
|
+
if (field.facet !== undefined) result["facet"] = field.facet;
|
|
16
|
+
if (field.index !== undefined) result["index"] = field.index;
|
|
17
|
+
if (field.sort !== undefined) result["sort"] = field.sort;
|
|
18
|
+
if (field.infix !== undefined) result["infix"] = field.infix;
|
|
19
|
+
if (field.locale !== undefined) result["locale"] = field.locale;
|
|
20
|
+
if (field.stem !== undefined) result["stem"] = field.stem;
|
|
21
|
+
if (field.store !== undefined) result["store"] = field.store;
|
|
22
|
+
if (field.num_dim !== undefined) result["num_dim"] = field.num_dim;
|
|
23
|
+
if (field.vec_dist !== undefined) result["vec_dist"] = field.vec_dist;
|
|
24
|
+
if (field.reference !== undefined) result["reference"] = field.reference;
|
|
25
|
+
if (field.range_index !== undefined) result["range_index"] = field.range_index;
|
|
26
|
+
if (field.stem_dictionary !== undefined) result["stem_dictionary"] = field.stem_dictionary;
|
|
27
|
+
if (field.truncate_len !== undefined) result["truncate_len"] = field.truncate_len;
|
|
28
|
+
if (field.token_separators !== undefined) result["token_separators"] = field.token_separators;
|
|
29
|
+
if (field.symbols_to_index !== undefined) result["symbols_to_index"] = field.symbols_to_index;
|
|
30
|
+
if (field.embed !== undefined) result["embed"] = field.embed;
|
|
31
|
+
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Field default values in Typesense
|
|
37
|
+
* These are stripped when importing to keep configs minimal
|
|
38
|
+
*/
|
|
39
|
+
const FIELD_DEFAULTS = {
|
|
40
|
+
optional: false,
|
|
41
|
+
facet: false,
|
|
42
|
+
index: true,
|
|
43
|
+
sort: false,
|
|
44
|
+
infix: false,
|
|
45
|
+
stem: false,
|
|
46
|
+
store: true,
|
|
47
|
+
range_index: false,
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Types that have sort enabled by default in Typesense
|
|
52
|
+
* Typesense auto-enables sort for numeric and bool types
|
|
53
|
+
*/
|
|
54
|
+
const SORT_ENABLED_BY_DEFAULT_TYPES = new Set([
|
|
55
|
+
"int32", "int32[]",
|
|
56
|
+
"int64", "int64[]",
|
|
57
|
+
"float", "float[]",
|
|
58
|
+
"bool", "bool[]",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Convert Typesense field to our Field type
|
|
63
|
+
* Strips default values to keep config minimal
|
|
64
|
+
*/
|
|
65
|
+
function fromTypesenseField(f: Record<string, unknown>): Field {
|
|
66
|
+
const fieldType = f["type"] as string;
|
|
67
|
+
const field: Field = {
|
|
68
|
+
name: f["name"] as string,
|
|
69
|
+
type: fieldType as Field["type"],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Only include non-default values
|
|
73
|
+
if (f["optional"] !== undefined && f["optional"] !== FIELD_DEFAULTS.optional)
|
|
74
|
+
field.optional = f["optional"] as boolean;
|
|
75
|
+
if (f["facet"] !== undefined && f["facet"] !== FIELD_DEFAULTS.facet)
|
|
76
|
+
field.facet = f["facet"] as boolean;
|
|
77
|
+
if (f["index"] !== undefined && f["index"] !== FIELD_DEFAULTS.index)
|
|
78
|
+
field.index = f["index"] as boolean;
|
|
79
|
+
|
|
80
|
+
// sort: Typesense auto-enables for numeric/bool types, so only include if different from type default
|
|
81
|
+
const sortDefault = SORT_ENABLED_BY_DEFAULT_TYPES.has(fieldType);
|
|
82
|
+
if (f["sort"] !== undefined && f["sort"] !== sortDefault)
|
|
83
|
+
field.sort = f["sort"] as boolean;
|
|
84
|
+
|
|
85
|
+
if (f["infix"] !== undefined && f["infix"] !== FIELD_DEFAULTS.infix)
|
|
86
|
+
field.infix = f["infix"] as boolean;
|
|
87
|
+
if (f["stem"] !== undefined && f["stem"] !== FIELD_DEFAULTS.stem)
|
|
88
|
+
field.stem = f["stem"] as boolean;
|
|
89
|
+
if (f["store"] !== undefined && f["store"] !== FIELD_DEFAULTS.store)
|
|
90
|
+
field.store = f["store"] as boolean;
|
|
91
|
+
if (f["range_index"] !== undefined && f["range_index"] !== FIELD_DEFAULTS.range_index)
|
|
92
|
+
field.range_index = f["range_index"] as boolean;
|
|
93
|
+
|
|
94
|
+
// locale: only include if non-empty string
|
|
95
|
+
if (f["locale"] !== undefined && f["locale"] !== "")
|
|
96
|
+
field.locale = f["locale"] as string;
|
|
97
|
+
|
|
98
|
+
// These have no defaults, always include if present
|
|
99
|
+
if (f["num_dim"] !== undefined) field.num_dim = f["num_dim"] as number;
|
|
100
|
+
if (f["vec_dist"] !== undefined) field.vec_dist = f["vec_dist"] as "cosine" | "ip";
|
|
101
|
+
if (f["reference"] !== undefined) field.reference = f["reference"] as string;
|
|
102
|
+
if (f["embed"] !== undefined) field.embed = f["embed"] as Field["embed"];
|
|
103
|
+
if (f["stem_dictionary"] !== undefined) field.stem_dictionary = f["stem_dictionary"] as string;
|
|
104
|
+
if (f["truncate_len"] !== undefined && f["truncate_len"] !== 100) field.truncate_len = f["truncate_len"] as number;
|
|
105
|
+
// Field-level token_separators and symbols_to_index
|
|
106
|
+
const fieldTokenSeparators = f["token_separators"] as string[] | undefined;
|
|
107
|
+
if (fieldTokenSeparators && fieldTokenSeparators.length > 0) field.token_separators = fieldTokenSeparators;
|
|
108
|
+
const fieldSymbolsToIndex = f["symbols_to_index"] as string[] | undefined;
|
|
109
|
+
if (fieldSymbolsToIndex && fieldSymbolsToIndex.length > 0) field.symbols_to_index = fieldSymbolsToIndex;
|
|
110
|
+
|
|
111
|
+
return field;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Convert our collection config to Typesense schema
|
|
116
|
+
*/
|
|
117
|
+
function toTypesenseSchema(config: CollectionConfig): CollectionCreateSchema {
|
|
118
|
+
const schema: Record<string, unknown> = {
|
|
119
|
+
name: config.name,
|
|
120
|
+
fields: config.fields.map(toTypesenseField),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (config.default_sorting_field) {
|
|
124
|
+
schema["default_sorting_field"] = config.default_sorting_field;
|
|
125
|
+
}
|
|
126
|
+
if (config.token_separators) {
|
|
127
|
+
schema["token_separators"] = config.token_separators;
|
|
128
|
+
}
|
|
129
|
+
if (config.symbols_to_index) {
|
|
130
|
+
schema["symbols_to_index"] = config.symbols_to_index;
|
|
131
|
+
}
|
|
132
|
+
if (config.enable_nested_fields !== undefined) {
|
|
133
|
+
schema["enable_nested_fields"] = config.enable_nested_fields;
|
|
134
|
+
}
|
|
135
|
+
// Typesense 30.0+: synonym sets linking
|
|
136
|
+
if (config.synonym_sets && config.synonym_sets.length > 0) {
|
|
137
|
+
schema["synonym_sets"] = config.synonym_sets;
|
|
138
|
+
}
|
|
139
|
+
// Typesense 30.0+: curation sets linking
|
|
140
|
+
if (config.curation_sets && config.curation_sets.length > 0) {
|
|
141
|
+
schema["curation_sets"] = config.curation_sets;
|
|
142
|
+
}
|
|
143
|
+
// Custom metadata
|
|
144
|
+
if (config.metadata && Object.keys(config.metadata).length > 0) {
|
|
145
|
+
schema["metadata"] = config.metadata;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return schema as unknown as CollectionCreateSchema;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get a collection from Typesense
|
|
153
|
+
*/
|
|
154
|
+
export async function getCollection(
|
|
155
|
+
name: string
|
|
156
|
+
): Promise<CollectionConfig | null> {
|
|
157
|
+
const client = getClient();
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const collection = await client.collections(name).retrieve();
|
|
161
|
+
const collectionData = collection as unknown as Record<string, unknown>;
|
|
162
|
+
const fields = collectionData["fields"] as Array<Record<string, unknown>> | undefined;
|
|
163
|
+
|
|
164
|
+
// Convert back to our config format
|
|
165
|
+
const config: CollectionConfig = {
|
|
166
|
+
name: collectionData["name"] as string,
|
|
167
|
+
fields: (fields || []).map(fromTypesenseField),
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
if (collectionData["default_sorting_field"]) {
|
|
171
|
+
config.default_sorting_field = collectionData["default_sorting_field"] as string;
|
|
172
|
+
}
|
|
173
|
+
// Only include if non-empty array
|
|
174
|
+
const tokenSeparators = collectionData["token_separators"] as string[] | undefined;
|
|
175
|
+
if (tokenSeparators && tokenSeparators.length > 0) {
|
|
176
|
+
config.token_separators = tokenSeparators;
|
|
177
|
+
}
|
|
178
|
+
// Only include if non-empty array
|
|
179
|
+
const symbolsToIndex = collectionData["symbols_to_index"] as string[] | undefined;
|
|
180
|
+
if (symbolsToIndex && symbolsToIndex.length > 0) {
|
|
181
|
+
config.symbols_to_index = symbolsToIndex;
|
|
182
|
+
}
|
|
183
|
+
// Only include enable_nested_fields if not the default (false)
|
|
184
|
+
if (collectionData["enable_nested_fields"] === true) {
|
|
185
|
+
config.enable_nested_fields = true;
|
|
186
|
+
}
|
|
187
|
+
// Typesense 30.0+: synonym sets linking
|
|
188
|
+
const synonymSets = collectionData["synonym_sets"] as string[] | undefined;
|
|
189
|
+
if (synonymSets && synonymSets.length > 0) {
|
|
190
|
+
config.synonym_sets = synonymSets;
|
|
191
|
+
}
|
|
192
|
+
// Typesense 30.0+: curation sets linking
|
|
193
|
+
const curationSets = collectionData["curation_sets"] as string[] | undefined;
|
|
194
|
+
if (curationSets && curationSets.length > 0) {
|
|
195
|
+
config.curation_sets = curationSets;
|
|
196
|
+
}
|
|
197
|
+
// Custom metadata
|
|
198
|
+
const metadata = collectionData["metadata"] as Record<string, unknown> | undefined;
|
|
199
|
+
if (metadata && Object.keys(metadata).length > 0) {
|
|
200
|
+
config.metadata = metadata;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return config;
|
|
204
|
+
} catch (error: unknown) {
|
|
205
|
+
if (
|
|
206
|
+
error &&
|
|
207
|
+
typeof error === "object" &&
|
|
208
|
+
"httpStatus" in error &&
|
|
209
|
+
error.httpStatus === 404
|
|
210
|
+
) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* List all collections from Typesense
|
|
219
|
+
*/
|
|
220
|
+
export async function listCollections(): Promise<CollectionConfig[]> {
|
|
221
|
+
const client = getClient();
|
|
222
|
+
const collections = await client.collections().retrieve();
|
|
223
|
+
|
|
224
|
+
return (collections as unknown as Array<Record<string, unknown>>)
|
|
225
|
+
.filter((c) => !(c["name"] as string).startsWith("_tsctl_")) // Exclude our state collection
|
|
226
|
+
.map((collection) => {
|
|
227
|
+
const fields = collection["fields"] as Array<Record<string, unknown>> | undefined;
|
|
228
|
+
|
|
229
|
+
const config: CollectionConfig = {
|
|
230
|
+
name: collection["name"] as string,
|
|
231
|
+
fields: (fields || []).map(fromTypesenseField),
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
if (collection["default_sorting_field"]) {
|
|
235
|
+
config.default_sorting_field = collection["default_sorting_field"] as string;
|
|
236
|
+
}
|
|
237
|
+
// Only include if non-empty array
|
|
238
|
+
const tokenSeparators = collection["token_separators"] as string[] | undefined;
|
|
239
|
+
if (tokenSeparators && tokenSeparators.length > 0) {
|
|
240
|
+
config.token_separators = tokenSeparators;
|
|
241
|
+
}
|
|
242
|
+
// Only include if non-empty array
|
|
243
|
+
const symbolsToIndex = collection["symbols_to_index"] as string[] | undefined;
|
|
244
|
+
if (symbolsToIndex && symbolsToIndex.length > 0) {
|
|
245
|
+
config.symbols_to_index = symbolsToIndex;
|
|
246
|
+
}
|
|
247
|
+
// Only include enable_nested_fields if not the default (false)
|
|
248
|
+
if (collection["enable_nested_fields"] === true) {
|
|
249
|
+
config.enable_nested_fields = true;
|
|
250
|
+
}
|
|
251
|
+
// Typesense 30.0+: synonym sets linking
|
|
252
|
+
const synonymSets = collection["synonym_sets"] as string[] | undefined;
|
|
253
|
+
if (synonymSets && synonymSets.length > 0) {
|
|
254
|
+
config.synonym_sets = synonymSets;
|
|
255
|
+
}
|
|
256
|
+
// Typesense 30.0+: curation sets linking
|
|
257
|
+
const curationSets = collection["curation_sets"] as string[] | undefined;
|
|
258
|
+
if (curationSets && curationSets.length > 0) {
|
|
259
|
+
config.curation_sets = curationSets;
|
|
260
|
+
}
|
|
261
|
+
// Custom metadata
|
|
262
|
+
const metadata = collection["metadata"] as Record<string, unknown> | undefined;
|
|
263
|
+
if (metadata && Object.keys(metadata).length > 0) {
|
|
264
|
+
config.metadata = metadata;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return config;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Create a new collection in Typesense
|
|
273
|
+
*/
|
|
274
|
+
export async function createCollection(config: CollectionConfig): Promise<void> {
|
|
275
|
+
const client = getClient();
|
|
276
|
+
const schema = toTypesenseSchema(config);
|
|
277
|
+
await client.collections().create(schema);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Check if two fields are equal (ignoring order of properties)
|
|
282
|
+
*/
|
|
283
|
+
function fieldsEqual(a: Field, b: Field): boolean {
|
|
284
|
+
return JSON.stringify(normalizeField(a)) === JSON.stringify(normalizeField(b));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Normalize a field for comparison
|
|
289
|
+
*/
|
|
290
|
+
function normalizeField(f: Field): Record<string, unknown> {
|
|
291
|
+
const result: Record<string, unknown> = {
|
|
292
|
+
name: f.name,
|
|
293
|
+
type: f.type,
|
|
294
|
+
};
|
|
295
|
+
if (f.optional !== undefined) result.optional = f.optional;
|
|
296
|
+
if (f.facet !== undefined) result.facet = f.facet;
|
|
297
|
+
if (f.index !== undefined) result.index = f.index;
|
|
298
|
+
if (f.sort !== undefined) result.sort = f.sort;
|
|
299
|
+
if (f.infix !== undefined) result.infix = f.infix;
|
|
300
|
+
if (f.locale !== undefined) result.locale = f.locale;
|
|
301
|
+
if (f.stem !== undefined) result.stem = f.stem;
|
|
302
|
+
if (f.store !== undefined) result.store = f.store;
|
|
303
|
+
if (f.num_dim !== undefined) result.num_dim = f.num_dim;
|
|
304
|
+
if (f.vec_dist !== undefined) result.vec_dist = f.vec_dist;
|
|
305
|
+
if (f.reference !== undefined) result.reference = f.reference;
|
|
306
|
+
if (f.range_index !== undefined) result.range_index = f.range_index;
|
|
307
|
+
if (f.stem_dictionary !== undefined) result.stem_dictionary = f.stem_dictionary;
|
|
308
|
+
if (f.truncate_len !== undefined) result.truncate_len = f.truncate_len;
|
|
309
|
+
if (f.token_separators !== undefined) result.token_separators = f.token_separators;
|
|
310
|
+
if (f.symbols_to_index !== undefined) result.symbols_to_index = f.symbols_to_index;
|
|
311
|
+
if (f.embed !== undefined) result.embed = f.embed;
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Update a collection in Typesense
|
|
317
|
+
* Handles field modifications by dropping and re-adding fields
|
|
318
|
+
* Also handles synonym_sets updates
|
|
319
|
+
*/
|
|
320
|
+
export async function updateCollection(
|
|
321
|
+
config: CollectionConfig,
|
|
322
|
+
_existing: CollectionConfig
|
|
323
|
+
): Promise<{ requiresRecreate: boolean; fieldsToAdd: Field[]; fieldsToDrop: string[]; fieldsToModify: Field[] }> {
|
|
324
|
+
const client = getClient();
|
|
325
|
+
|
|
326
|
+
const existingFieldNames = new Set(_existing.fields.map((f) => f.name));
|
|
327
|
+
const newFieldNames = new Set(config.fields.map((f) => f.name));
|
|
328
|
+
const existingFieldMap = new Map(_existing.fields.map((f) => [f.name, f]));
|
|
329
|
+
|
|
330
|
+
// Fields to add (new fields not in existing)
|
|
331
|
+
const fieldsToAdd = config.fields.filter((f) => !existingFieldNames.has(f.name));
|
|
332
|
+
|
|
333
|
+
// Fields to drop (in existing but not in new config)
|
|
334
|
+
const fieldsToDrop = _existing.fields
|
|
335
|
+
.filter((f) => !newFieldNames.has(f.name))
|
|
336
|
+
.map((f) => f.name);
|
|
337
|
+
|
|
338
|
+
// Fields to modify (exist in both but have different config)
|
|
339
|
+
// These will be dropped and re-added
|
|
340
|
+
const fieldsToModify: Field[] = [];
|
|
341
|
+
for (const field of config.fields) {
|
|
342
|
+
const existingField = existingFieldMap.get(field.name);
|
|
343
|
+
if (existingField && !fieldsEqual(existingField, field)) {
|
|
344
|
+
fieldsToModify.push(field);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Build update schema
|
|
349
|
+
const updateSchema: Record<string, unknown> = {};
|
|
350
|
+
|
|
351
|
+
// Build field updates
|
|
352
|
+
const updateFields: Array<Record<string, unknown>> = [];
|
|
353
|
+
|
|
354
|
+
// First, drop fields that need to be removed or modified
|
|
355
|
+
for (const name of fieldsToDrop) {
|
|
356
|
+
updateFields.push({ name, drop: true });
|
|
357
|
+
}
|
|
358
|
+
for (const field of fieldsToModify) {
|
|
359
|
+
updateFields.push({ name: field.name, drop: true });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Then, add new fields and re-add modified fields
|
|
363
|
+
for (const field of fieldsToAdd) {
|
|
364
|
+
updateFields.push(toTypesenseField(field));
|
|
365
|
+
}
|
|
366
|
+
for (const field of fieldsToModify) {
|
|
367
|
+
updateFields.push(toTypesenseField(field));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (updateFields.length > 0) {
|
|
371
|
+
updateSchema.fields = updateFields;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Check if synonym_sets changed
|
|
375
|
+
const existingSynonymSets = _existing.synonym_sets || [];
|
|
376
|
+
const newSynonymSets = config.synonym_sets || [];
|
|
377
|
+
const synonymSetsChanged =
|
|
378
|
+
JSON.stringify(existingSynonymSets.sort()) !== JSON.stringify(newSynonymSets.sort());
|
|
379
|
+
|
|
380
|
+
if (synonymSetsChanged) {
|
|
381
|
+
updateSchema.synonym_sets = newSynonymSets;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Check if curation_sets changed
|
|
385
|
+
const existingCurationSets = _existing.curation_sets || [];
|
|
386
|
+
const newCurationSets = config.curation_sets || [];
|
|
387
|
+
const curationSetsChanged =
|
|
388
|
+
JSON.stringify(existingCurationSets.sort()) !== JSON.stringify(newCurationSets.sort());
|
|
389
|
+
|
|
390
|
+
if (curationSetsChanged) {
|
|
391
|
+
updateSchema.curation_sets = newCurationSets;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Check if metadata changed
|
|
395
|
+
const existingMetadata = _existing.metadata || {};
|
|
396
|
+
const newMetadata = config.metadata || {};
|
|
397
|
+
if (JSON.stringify(existingMetadata) !== JSON.stringify(newMetadata)) {
|
|
398
|
+
updateSchema.metadata = newMetadata;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Apply changes if any
|
|
402
|
+
if (Object.keys(updateSchema).length > 0) {
|
|
403
|
+
await client.collections(config.name).update(updateSchema as never);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return { requiresRecreate: false, fieldsToAdd, fieldsToDrop, fieldsToModify };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Delete a collection from Typesense
|
|
411
|
+
*/
|
|
412
|
+
export async function deleteCollection(name: string): Promise<void> {
|
|
413
|
+
const client = getClient();
|
|
414
|
+
await client.collections(name).delete();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Recreate a collection (delete and create)
|
|
419
|
+
* WARNING: This will delete all documents!
|
|
420
|
+
*/
|
|
421
|
+
export async function recreateCollection(config: CollectionConfig): Promise<void> {
|
|
422
|
+
await deleteCollection(config.name);
|
|
423
|
+
await createCollection(config);
|
|
424
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { getClient } from "../client/index.js";
|
|
2
|
+
import type { CurationSetConfig, CurationItem } from "../types/index.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Convert Typesense curation item to our CurationItem format
|
|
6
|
+
* Strips default values to keep config minimal
|
|
7
|
+
*/
|
|
8
|
+
function fromTypesenseCurationItem(
|
|
9
|
+
item: Record<string, unknown>
|
|
10
|
+
): CurationItem {
|
|
11
|
+
const config: CurationItem = {
|
|
12
|
+
id: item.id as string,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
if (item.rule) config.rule = item.rule as CurationItem["rule"];
|
|
16
|
+
if (item.includes)
|
|
17
|
+
config.includes = item.includes as CurationItem["includes"];
|
|
18
|
+
if (item.excludes)
|
|
19
|
+
config.excludes = item.excludes as CurationItem["excludes"];
|
|
20
|
+
if (item.filter_by) config.filter_by = item.filter_by as string;
|
|
21
|
+
if (item.sort_by) config.sort_by = item.sort_by as string;
|
|
22
|
+
if (item.replace_query)
|
|
23
|
+
config.replace_query = item.replace_query as string;
|
|
24
|
+
if (item.metadata && Object.keys(item.metadata as object).length > 0)
|
|
25
|
+
config.metadata = item.metadata as Record<string, unknown>;
|
|
26
|
+
|
|
27
|
+
// Only include non-default values
|
|
28
|
+
if (
|
|
29
|
+
item.remove_matched_tokens !== undefined &&
|
|
30
|
+
item.remove_matched_tokens !== true
|
|
31
|
+
)
|
|
32
|
+
config.remove_matched_tokens = item.remove_matched_tokens as boolean;
|
|
33
|
+
if (
|
|
34
|
+
item.filter_curated_hits !== undefined &&
|
|
35
|
+
item.filter_curated_hits !== false
|
|
36
|
+
)
|
|
37
|
+
config.filter_curated_hits = item.filter_curated_hits as boolean;
|
|
38
|
+
if (
|
|
39
|
+
item.stop_processing !== undefined &&
|
|
40
|
+
item.stop_processing !== true
|
|
41
|
+
)
|
|
42
|
+
config.stop_processing = item.stop_processing as boolean;
|
|
43
|
+
|
|
44
|
+
if (item.effective_from_ts !== undefined)
|
|
45
|
+
config.effective_from_ts = item.effective_from_ts as number;
|
|
46
|
+
if (item.effective_to_ts !== undefined)
|
|
47
|
+
config.effective_to_ts = item.effective_to_ts as number;
|
|
48
|
+
|
|
49
|
+
return config;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get a curation set from Typesense
|
|
54
|
+
*/
|
|
55
|
+
export async function getCurationSet(
|
|
56
|
+
name: string
|
|
57
|
+
): Promise<CurationSetConfig | null> {
|
|
58
|
+
const client = getClient();
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const data = await client.curationSets(name).retrieve();
|
|
62
|
+
const items = (data.items || []) as unknown as Array<Record<string, unknown>>;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
name,
|
|
66
|
+
items: items.map(fromTypesenseCurationItem),
|
|
67
|
+
};
|
|
68
|
+
} catch (error: unknown) {
|
|
69
|
+
if (
|
|
70
|
+
error &&
|
|
71
|
+
typeof error === "object" &&
|
|
72
|
+
"httpStatus" in error &&
|
|
73
|
+
error.httpStatus === 404
|
|
74
|
+
) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* List all curation sets from Typesense
|
|
83
|
+
*/
|
|
84
|
+
export async function listCurationSets(): Promise<CurationSetConfig[]> {
|
|
85
|
+
const client = getClient();
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const sets = await client.curationSets().retrieve();
|
|
89
|
+
return sets.map((set) => ({
|
|
90
|
+
name: set.name,
|
|
91
|
+
items: (set.items || []).map((item) =>
|
|
92
|
+
fromTypesenseCurationItem(item as unknown as Record<string, unknown>)
|
|
93
|
+
),
|
|
94
|
+
}));
|
|
95
|
+
} catch (error: unknown) {
|
|
96
|
+
if (
|
|
97
|
+
error &&
|
|
98
|
+
typeof error === "object" &&
|
|
99
|
+
"httpStatus" in error &&
|
|
100
|
+
(error.httpStatus === 404 || error.httpStatus === 400)
|
|
101
|
+
) {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create a curation set in Typesense
|
|
110
|
+
*/
|
|
111
|
+
export async function createCurationSet(
|
|
112
|
+
config: CurationSetConfig
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
const client = getClient();
|
|
115
|
+
await client.curationSets(config.name).upsert({
|
|
116
|
+
items: config.items as any,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Update a curation set in Typesense
|
|
122
|
+
*/
|
|
123
|
+
export async function updateCurationSet(
|
|
124
|
+
config: CurationSetConfig,
|
|
125
|
+
_existing: CurationSetConfig
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
const client = getClient();
|
|
128
|
+
await client.curationSets(config.name).upsert({
|
|
129
|
+
items: config.items as any,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Delete a curation set from Typesense
|
|
135
|
+
*/
|
|
136
|
+
export async function deleteCurationSet(name: string): Promise<void> {
|
|
137
|
+
const client = getClient();
|
|
138
|
+
await client.curationSets(name).delete();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Compare two curation set configs for equality
|
|
143
|
+
*/
|
|
144
|
+
export function curationSetConfigsEqual(
|
|
145
|
+
a: CurationSetConfig,
|
|
146
|
+
b: CurationSetConfig
|
|
147
|
+
): boolean {
|
|
148
|
+
if (a.name !== b.name) return false;
|
|
149
|
+
if (a.items.length !== b.items.length) return false;
|
|
150
|
+
|
|
151
|
+
const aItems = [...a.items].sort((x, y) => x.id.localeCompare(y.id));
|
|
152
|
+
const bItems = [...b.items].sort((x, y) => x.id.localeCompare(y.id));
|
|
153
|
+
|
|
154
|
+
return JSON.stringify(aItems) === JSON.stringify(bItems);
|
|
155
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from "./collection.js";
|
|
2
|
+
export * from "./alias.js";
|
|
3
|
+
export * from "./synonym.js";
|
|
4
|
+
export * from "./synonymset.js";
|
|
5
|
+
export * from "./override.js";
|
|
6
|
+
export * from "./curationset.js";
|
|
7
|
+
export * from "./analyticsrule.js";
|
|
8
|
+
export * from "./apikey.js";
|
|
9
|
+
export * from "./stopword.js";
|
|
10
|
+
export * from "./preset.js";
|
|
11
|
+
export * from "./stemmingdictionary.js";
|