@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.
Files changed (45) hide show
  1. package/README.md +735 -0
  2. package/bin/tsctl.js +2 -0
  3. package/package.json +65 -0
  4. package/src/__tests__/analyticsrules.test.ts +303 -0
  5. package/src/__tests__/apikeys.test.ts +223 -0
  6. package/src/__tests__/apply.test.ts +245 -0
  7. package/src/__tests__/client.test.ts +48 -0
  8. package/src/__tests__/collection-advanced.test.ts +274 -0
  9. package/src/__tests__/config-loader.test.ts +217 -0
  10. package/src/__tests__/curationsets.test.ts +190 -0
  11. package/src/__tests__/helpers.ts +17 -0
  12. package/src/__tests__/import-drift.test.ts +231 -0
  13. package/src/__tests__/migrate-advanced.test.ts +197 -0
  14. package/src/__tests__/migrate.test.ts +220 -0
  15. package/src/__tests__/plan-new-resources.test.ts +258 -0
  16. package/src/__tests__/plan.test.ts +337 -0
  17. package/src/__tests__/presets.test.ts +97 -0
  18. package/src/__tests__/resources.test.ts +592 -0
  19. package/src/__tests__/setup.ts +77 -0
  20. package/src/__tests__/state.test.ts +312 -0
  21. package/src/__tests__/stemmingdictionaries.test.ts +111 -0
  22. package/src/__tests__/stopwords.test.ts +109 -0
  23. package/src/__tests__/synonymsets.test.ts +170 -0
  24. package/src/__tests__/types.test.ts +416 -0
  25. package/src/apply/index.ts +336 -0
  26. package/src/cli/index.ts +1106 -0
  27. package/src/client/index.ts +55 -0
  28. package/src/config/loader.ts +158 -0
  29. package/src/index.ts +45 -0
  30. package/src/migrate/index.ts +220 -0
  31. package/src/plan/index.ts +1333 -0
  32. package/src/resources/alias.ts +59 -0
  33. package/src/resources/analyticsrule.ts +134 -0
  34. package/src/resources/apikey.ts +203 -0
  35. package/src/resources/collection.ts +424 -0
  36. package/src/resources/curationset.ts +155 -0
  37. package/src/resources/index.ts +11 -0
  38. package/src/resources/override.ts +174 -0
  39. package/src/resources/preset.ts +83 -0
  40. package/src/resources/stemmingdictionary.ts +103 -0
  41. package/src/resources/stopword.ts +100 -0
  42. package/src/resources/synonym.ts +152 -0
  43. package/src/resources/synonymset.ts +144 -0
  44. package/src/state/index.ts +206 -0
  45. package/src/types/index.ts +451 -0
@@ -0,0 +1,416 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ defineConfig,
4
+ TypesenseConfigSchema,
5
+ CollectionConfigSchema,
6
+ AliasConfigSchema,
7
+ SynonymConfigSchema,
8
+ OverrideConfigSchema,
9
+ FieldSchema,
10
+ FieldTypeSchema,
11
+ AnalyticsRuleConfigSchema,
12
+ ApiKeyConfigSchema,
13
+ SynonymSetConfigSchema,
14
+ StopwordSetConfigSchema,
15
+ PresetConfigSchema,
16
+ CurationSetConfigSchema,
17
+ CurationItemSchema,
18
+ StemmingDictionaryConfigSchema,
19
+ } from "../types/index.js";
20
+
21
+ describe("types / schemas", () => {
22
+ describe("FieldTypeSchema", () => {
23
+ test("accepts valid field types", () => {
24
+ const validTypes = [
25
+ "string", "string[]", "int32", "int32[]", "int64", "int64[]",
26
+ "float", "float[]", "bool", "bool[]", "geopoint", "geopoint[]",
27
+ "geopolygon", "object", "object[]", "auto", "string*", "image",
28
+ ];
29
+ for (const type of validTypes) {
30
+ expect(FieldTypeSchema.parse(type)).toBe(type);
31
+ }
32
+ });
33
+
34
+ test("rejects invalid field types", () => {
35
+ expect(() => FieldTypeSchema.parse("invalid")).toThrow();
36
+ expect(() => FieldTypeSchema.parse("number")).toThrow();
37
+ });
38
+ });
39
+
40
+ describe("FieldSchema", () => {
41
+ test("parses minimal field", () => {
42
+ const field = FieldSchema.parse({ name: "title", type: "string" });
43
+ expect(field.name).toBe("title");
44
+ expect(field.type).toBe("string");
45
+ });
46
+
47
+ test("parses field with all options", () => {
48
+ const field = FieldSchema.parse({
49
+ name: "title",
50
+ type: "string",
51
+ optional: true,
52
+ facet: true,
53
+ index: true,
54
+ sort: true,
55
+ infix: true,
56
+ locale: "en",
57
+ stem: true,
58
+ store: true,
59
+ range_index: true,
60
+ stem_dictionary: "english-plurals",
61
+ truncate_len: 200,
62
+ token_separators: ["-"],
63
+ symbols_to_index: ["#"],
64
+ });
65
+ expect(field.optional).toBe(true);
66
+ expect(field.facet).toBe(true);
67
+ expect(field.locale).toBe("en");
68
+ expect(field.stem_dictionary).toBe("english-plurals");
69
+ expect(field.truncate_len).toBe(200);
70
+ expect(field.token_separators).toEqual(["-"]);
71
+ expect(field.symbols_to_index).toEqual(["#"]);
72
+ });
73
+
74
+ test("parses field with vector options", () => {
75
+ const field = FieldSchema.parse({
76
+ name: "embedding",
77
+ type: "float[]",
78
+ num_dim: 384,
79
+ vec_dist: "cosine",
80
+ });
81
+ expect(field.num_dim).toBe(384);
82
+ expect(field.vec_dist).toBe("cosine");
83
+ });
84
+
85
+ test("parses field with embed config", () => {
86
+ const field = FieldSchema.parse({
87
+ name: "embedding",
88
+ type: "float[]",
89
+ embed: {
90
+ from: ["title", "description"],
91
+ model_config: {
92
+ model_name: "ts/all-MiniLM-L12-v2",
93
+ },
94
+ },
95
+ });
96
+ expect(field.embed?.from).toEqual(["title", "description"]);
97
+ expect(field.embed?.model_config.model_name).toBe("ts/all-MiniLM-L12-v2");
98
+ });
99
+
100
+ test("rejects field without name", () => {
101
+ expect(() => FieldSchema.parse({ type: "string" })).toThrow();
102
+ });
103
+
104
+ test("rejects field without type", () => {
105
+ expect(() => FieldSchema.parse({ name: "title" })).toThrow();
106
+ });
107
+ });
108
+
109
+ describe("CollectionConfigSchema", () => {
110
+ test("parses minimal collection", () => {
111
+ const config = CollectionConfigSchema.parse({
112
+ name: "products",
113
+ fields: [{ name: "title", type: "string" }],
114
+ });
115
+ expect(config.name).toBe("products");
116
+ expect(config.fields).toHaveLength(1);
117
+ });
118
+
119
+ test("parses collection with all options", () => {
120
+ const config = CollectionConfigSchema.parse({
121
+ name: "products",
122
+ fields: [{ name: "title", type: "string" }],
123
+ default_sorting_field: "created_at",
124
+ token_separators: ["-", "/"],
125
+ symbols_to_index: ["#"],
126
+ enable_nested_fields: true,
127
+ synonym_sets: ["my-synonyms"],
128
+ });
129
+ expect(config.default_sorting_field).toBe("created_at");
130
+ expect(config.token_separators).toEqual(["-", "/"]);
131
+ expect(config.synonym_sets).toEqual(["my-synonyms"]);
132
+ });
133
+
134
+ test("rejects collection without name", () => {
135
+ expect(() =>
136
+ CollectionConfigSchema.parse({ fields: [{ name: "x", type: "string" }] })
137
+ ).toThrow();
138
+ });
139
+
140
+ test("rejects collection without fields", () => {
141
+ expect(() => CollectionConfigSchema.parse({ name: "test" })).toThrow();
142
+ });
143
+ });
144
+
145
+ describe("AliasConfigSchema", () => {
146
+ test("parses valid alias", () => {
147
+ const alias = AliasConfigSchema.parse({ name: "products_live", collection: "products" });
148
+ expect(alias.name).toBe("products_live");
149
+ expect(alias.collection).toBe("products");
150
+ });
151
+
152
+ test("rejects alias without collection", () => {
153
+ expect(() => AliasConfigSchema.parse({ name: "test" })).toThrow();
154
+ });
155
+ });
156
+
157
+ describe("SynonymConfigSchema", () => {
158
+ test("parses multi-way synonym", () => {
159
+ const synonym = SynonymConfigSchema.parse({
160
+ id: "phone-synonyms",
161
+ collection: "products",
162
+ synonyms: ["phone", "mobile", "smartphone"],
163
+ });
164
+ expect(synonym.id).toBe("phone-synonyms");
165
+ expect(synonym.synonyms).toHaveLength(3);
166
+ });
167
+
168
+ test("parses one-way synonym with root", () => {
169
+ const synonym = SynonymConfigSchema.parse({
170
+ id: "tv-synonym",
171
+ collection: "products",
172
+ root: "television",
173
+ synonyms: ["tv", "telly"],
174
+ });
175
+ expect(synonym.root).toBe("television");
176
+ });
177
+ });
178
+
179
+ describe("SynonymSetConfigSchema", () => {
180
+ test("parses synonym set with items", () => {
181
+ const set = SynonymSetConfigSchema.parse({
182
+ name: "my-synonyms",
183
+ items: [
184
+ { id: "phones", synonyms: ["phone", "mobile"] },
185
+ { id: "tv", root: "television", synonyms: ["tv", "telly"] },
186
+ ],
187
+ });
188
+ expect(set.name).toBe("my-synonyms");
189
+ expect(set.items).toHaveLength(2);
190
+ });
191
+ });
192
+
193
+ describe("OverrideConfigSchema", () => {
194
+ test("parses override with includes", () => {
195
+ const override = OverrideConfigSchema.parse({
196
+ id: "pin-featured",
197
+ collection: "products",
198
+ rule: { query: "featured", match: "exact" },
199
+ includes: [{ id: "product-123", position: 1 }],
200
+ });
201
+ expect(override.id).toBe("pin-featured");
202
+ expect(override.includes).toHaveLength(1);
203
+ });
204
+
205
+ test("parses override with all options", () => {
206
+ const override = OverrideConfigSchema.parse({
207
+ id: "boost",
208
+ collection: "products",
209
+ rule: { query: "shoes", match: "contains" },
210
+ filter_by: "category:=footwear",
211
+ sort_by: "popularity:desc",
212
+ remove_matched_tokens: true,
213
+ effective_from_ts: 1672531200,
214
+ effective_to_ts: 1704067200,
215
+ stop_processing: false,
216
+ });
217
+ expect(override.filter_by).toBe("category:=footwear");
218
+ expect(override.stop_processing).toBe(false);
219
+ });
220
+ });
221
+
222
+ describe("AnalyticsRuleConfigSchema", () => {
223
+ test("parses analytics rule", () => {
224
+ const rule = AnalyticsRuleConfigSchema.parse({
225
+ name: "popular-queries",
226
+ type: "popular_queries",
227
+ collection: "products",
228
+ event_type: "search",
229
+ });
230
+ expect(rule.name).toBe("popular-queries");
231
+ expect(rule.type).toBe("popular_queries");
232
+ });
233
+
234
+ test("parses analytics rule with params", () => {
235
+ const rule = AnalyticsRuleConfigSchema.parse({
236
+ name: "popular-queries",
237
+ type: "popular_queries",
238
+ collection: "products",
239
+ event_type: "search",
240
+ params: { limit: 100, destination_collection: "popular" },
241
+ });
242
+ expect(rule.params?.limit).toBe(100);
243
+ });
244
+ });
245
+
246
+ describe("ApiKeyConfigSchema", () => {
247
+ test("parses minimal API key config", () => {
248
+ const key = ApiKeyConfigSchema.parse({
249
+ description: "Search key",
250
+ actions: ["documents:search"],
251
+ collections: ["products"],
252
+ });
253
+ expect(key.description).toBe("Search key");
254
+ });
255
+
256
+ test("parses API key with expiration", () => {
257
+ const key = ApiKeyConfigSchema.parse({
258
+ description: "Temp key",
259
+ actions: ["*"],
260
+ collections: ["*"],
261
+ expires_at: 1735689600,
262
+ autodelete: true,
263
+ });
264
+ expect(key.expires_at).toBe(1735689600);
265
+ expect(key.autodelete).toBe(true);
266
+ });
267
+ });
268
+
269
+ describe("StopwordSetConfigSchema", () => {
270
+ test("parses stopword set", () => {
271
+ const set = StopwordSetConfigSchema.parse({
272
+ id: "english-stopwords",
273
+ stopwords: ["the", "a", "an"],
274
+ });
275
+ expect(set.id).toBe("english-stopwords");
276
+ expect(set.stopwords).toHaveLength(3);
277
+ });
278
+
279
+ test("parses stopword set with locale", () => {
280
+ const set = StopwordSetConfigSchema.parse({
281
+ id: "german",
282
+ stopwords: ["der", "die", "das"],
283
+ locale: "de",
284
+ });
285
+ expect(set.locale).toBe("de");
286
+ });
287
+ });
288
+
289
+ describe("PresetConfigSchema", () => {
290
+ test("parses preset", () => {
291
+ const preset = PresetConfigSchema.parse({
292
+ name: "listing_view",
293
+ value: { q: "*", sort_by: "popularity:desc" },
294
+ });
295
+ expect(preset.name).toBe("listing_view");
296
+ expect(preset.value).toBeDefined();
297
+ });
298
+ });
299
+
300
+ describe("CurationSetConfigSchema", () => {
301
+ test("parses curation set", () => {
302
+ const set = CurationSetConfigSchema.parse({
303
+ name: "product-curations",
304
+ items: [
305
+ {
306
+ id: "pin-featured",
307
+ rule: { query: "featured", match: "exact" },
308
+ includes: [{ id: "product-123", position: 1 }],
309
+ },
310
+ ],
311
+ });
312
+ expect(set.name).toBe("product-curations");
313
+ expect(set.items).toHaveLength(1);
314
+ });
315
+
316
+ test("parses curation item with all options", () => {
317
+ const item = CurationItemSchema.parse({
318
+ id: "boost",
319
+ rule: { query: "shoes", match: "contains", filter_by: "brand:=Nike", tags: ["promo"] },
320
+ filter_by: "category:=footwear",
321
+ sort_by: "popularity:desc",
322
+ replace_query: "running shoes",
323
+ remove_matched_tokens: false,
324
+ filter_curated_hits: true,
325
+ stop_processing: false,
326
+ metadata: { campaign: "spring-sale" },
327
+ effective_from_ts: 1672531200,
328
+ effective_to_ts: 1704067200,
329
+ });
330
+ expect(item.rule?.tags).toEqual(["promo"]);
331
+ expect(item.metadata?.campaign).toBe("spring-sale");
332
+ expect(item.stop_processing).toBe(false);
333
+ });
334
+ });
335
+
336
+ describe("StemmingDictionaryConfigSchema", () => {
337
+ test("parses stemming dictionary", () => {
338
+ const dict = StemmingDictionaryConfigSchema.parse({
339
+ id: "english-plurals",
340
+ words: [
341
+ { word: "dogs", root: "dog" },
342
+ { word: "cats", root: "cat" },
343
+ ],
344
+ });
345
+ expect(dict.id).toBe("english-plurals");
346
+ expect(dict.words).toHaveLength(2);
347
+ });
348
+ });
349
+
350
+ describe("CollectionConfigSchema with v30 fields", () => {
351
+ test("parses collection with metadata", () => {
352
+ const config = CollectionConfigSchema.parse({
353
+ name: "products",
354
+ fields: [{ name: "title", type: "string" }],
355
+ metadata: { team: "search", version: 2 },
356
+ });
357
+ expect(config.metadata?.team).toBe("search");
358
+ });
359
+
360
+ test("parses collection with curation_sets", () => {
361
+ const config = CollectionConfigSchema.parse({
362
+ name: "products",
363
+ fields: [{ name: "title", type: "string" }],
364
+ curation_sets: ["product-curations"],
365
+ });
366
+ expect(config.curation_sets).toEqual(["product-curations"]);
367
+ });
368
+ });
369
+
370
+ describe("TypesenseConfigSchema", () => {
371
+ test("parses empty config", () => {
372
+ const config = TypesenseConfigSchema.parse({});
373
+ expect(config.collections).toBeUndefined();
374
+ });
375
+
376
+ test("parses full config", () => {
377
+ const config = TypesenseConfigSchema.parse({
378
+ collections: [{ name: "products", fields: [{ name: "title", type: "string" }] }],
379
+ aliases: [{ name: "products_live", collection: "products" }],
380
+ synonyms: [{ id: "syn1", collection: "products", synonyms: ["a", "b"] }],
381
+ overrides: [{ id: "ov1", collection: "products", rule: { query: "x", match: "exact" } }],
382
+ apiKeys: [{ description: "key1", actions: ["*"], collections: ["*"] }],
383
+ stopwords: [{ id: "sw1", stopwords: ["the", "a"] }],
384
+ presets: [{ name: "p1", value: { q: "*" } }],
385
+ curationSets: [{ name: "cs1", items: [{ id: "r1", rule: { query: "x", match: "exact" } }] }],
386
+ stemmingDictionaries: [{ id: "d1", words: [{ word: "dogs", root: "dog" }] }],
387
+ });
388
+ expect(config.collections).toHaveLength(1);
389
+ expect(config.aliases).toHaveLength(1);
390
+ expect(config.synonyms).toHaveLength(1);
391
+ expect(config.overrides).toHaveLength(1);
392
+ expect(config.apiKeys).toHaveLength(1);
393
+ expect(config.stopwords).toHaveLength(1);
394
+ expect(config.presets).toHaveLength(1);
395
+ expect(config.curationSets).toHaveLength(1);
396
+ expect(config.stemmingDictionaries).toHaveLength(1);
397
+ });
398
+ });
399
+
400
+ describe("defineConfig", () => {
401
+ test("validates and returns config", () => {
402
+ const config = defineConfig({
403
+ collections: [{ name: "test", fields: [{ name: "title", type: "string" }] }],
404
+ });
405
+ expect(config.collections).toHaveLength(1);
406
+ });
407
+
408
+ test("throws on invalid config", () => {
409
+ expect(() =>
410
+ defineConfig({
411
+ collections: [{ name: "test", fields: [{ name: "x", type: "invalid" as any }] }],
412
+ })
413
+ ).toThrow();
414
+ });
415
+ });
416
+ });