@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,337 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { setupClient, cleanupTypesense } from "./setup.js";
3
+ import { getTypesenseVersion } from "./helpers.js";
4
+ import { buildPlan, buildNewState } from "../plan/index.js";
5
+ import { createCollection } from "../resources/collection.js";
6
+ import { upsertAlias } from "../resources/alias.js";
7
+ import { upsertSynonym } from "../resources/synonym.js";
8
+ import { upsertOverride } from "../resources/override.js";
9
+ import { saveState } from "../state/index.js";
10
+ import type { TypesenseConfig, State } from "../types/index.js";
11
+
12
+ describe("plan", () => {
13
+ beforeEach(async () => {
14
+ setupClient();
15
+ await cleanupTypesense();
16
+ });
17
+
18
+ afterEach(async () => {
19
+ await cleanupTypesense();
20
+ });
21
+
22
+ describe("buildPlan", () => {
23
+ test("plans creation for new collection", async () => {
24
+ const config: TypesenseConfig = {
25
+ collections: [
26
+ {
27
+ name: "products",
28
+ fields: [{ name: "title", type: "string" }],
29
+ },
30
+ ],
31
+ };
32
+
33
+ const plan = await buildPlan(config);
34
+ expect(plan.hasChanges).toBe(true);
35
+ expect(plan.summary.create).toBe(1);
36
+ expect(plan.changes[0]!.action).toBe("create");
37
+ expect(plan.changes[0]!.identifier.name).toBe("products");
38
+ });
39
+
40
+ test("plans no-change for existing matching collection", async () => {
41
+ const collectionConfig = {
42
+ name: "products",
43
+ fields: [{ name: "title", type: "string" }],
44
+ };
45
+
46
+ await createCollection(collectionConfig);
47
+
48
+ const config: TypesenseConfig = {
49
+ collections: [collectionConfig],
50
+ };
51
+
52
+ const plan = await buildPlan(config);
53
+ expect(plan.hasChanges).toBe(false);
54
+ expect(plan.summary.noChange).toBe(1);
55
+ });
56
+
57
+ test("plans update for modified collection", async () => {
58
+ await createCollection({
59
+ name: "products",
60
+ fields: [{ name: "title", type: "string" }],
61
+ });
62
+
63
+ const config: TypesenseConfig = {
64
+ collections: [
65
+ {
66
+ name: "products",
67
+ fields: [
68
+ { name: "title", type: "string", facet: true },
69
+ ],
70
+ },
71
+ ],
72
+ };
73
+
74
+ const plan = await buildPlan(config);
75
+ expect(plan.hasChanges).toBe(true);
76
+ expect(plan.summary.update).toBe(1);
77
+ });
78
+
79
+ test("plans deletion for resource in state but not in config", async () => {
80
+ await createCollection({
81
+ name: "products",
82
+ fields: [{ name: "title", type: "string" }],
83
+ });
84
+
85
+ // Save state with the collection
86
+ const state: State = {
87
+ version: "1.0",
88
+ resources: [
89
+ {
90
+ identifier: { type: "collection", name: "products" },
91
+ config: { name: "products", fields: [{ name: "title", type: "string" }] },
92
+ checksum: "abc",
93
+ lastUpdated: new Date().toISOString(),
94
+ },
95
+ ],
96
+ };
97
+ await saveState(state);
98
+
99
+ // Empty config = should plan deletion
100
+ const plan = await buildPlan({});
101
+ expect(plan.hasChanges).toBe(true);
102
+ expect(plan.summary.delete).toBe(1);
103
+ });
104
+
105
+ test("plans creation for new alias", async () => {
106
+ await createCollection({
107
+ name: "products",
108
+ fields: [{ name: "title", type: "string" }],
109
+ });
110
+
111
+ const config: TypesenseConfig = {
112
+ collections: [
113
+ { name: "products", fields: [{ name: "title", type: "string" }] },
114
+ ],
115
+ aliases: [{ name: "products_live", collection: "products" }],
116
+ };
117
+
118
+ const plan = await buildPlan(config);
119
+ const aliasChange = plan.changes.find(
120
+ (c) => c.identifier.type === "alias"
121
+ );
122
+ expect(aliasChange).toBeDefined();
123
+ expect(aliasChange!.action).toBe("create");
124
+ });
125
+
126
+ test("plans no-change for existing alias", async () => {
127
+ await createCollection({
128
+ name: "products",
129
+ fields: [{ name: "title", type: "string" }],
130
+ });
131
+ await upsertAlias({ name: "products_live", collection: "products" });
132
+
133
+ const config: TypesenseConfig = {
134
+ collections: [
135
+ { name: "products", fields: [{ name: "title", type: "string" }] },
136
+ ],
137
+ aliases: [{ name: "products_live", collection: "products" }],
138
+ };
139
+
140
+ const plan = await buildPlan(config);
141
+ const aliasChange = plan.changes.find(
142
+ (c) => c.identifier.type === "alias"
143
+ );
144
+ expect(aliasChange!.action).toBe("no-change");
145
+ });
146
+
147
+ test("plans creation for new synonym (pre-v30)", async () => {
148
+ const version = await getTypesenseVersion();
149
+ if (version >= 30) return;
150
+ await createCollection({
151
+ name: "products",
152
+ fields: [{ name: "title", type: "string" }],
153
+ });
154
+
155
+ const config: TypesenseConfig = {
156
+ collections: [
157
+ { name: "products", fields: [{ name: "title", type: "string" }] },
158
+ ],
159
+ synonyms: [
160
+ { id: "phone-syn", collection: "products", synonyms: ["phone", "mobile"] },
161
+ ],
162
+ };
163
+
164
+ const plan = await buildPlan(config);
165
+ const synChange = plan.changes.find(
166
+ (c) => c.identifier.type === "synonym"
167
+ );
168
+ expect(synChange).toBeDefined();
169
+ expect(synChange!.action).toBe("create");
170
+ });
171
+
172
+ test("plans creation for new override (pre-v30)", async () => {
173
+ const version = await getTypesenseVersion();
174
+ if (version >= 30) return;
175
+ await createCollection({
176
+ name: "products",
177
+ fields: [{ name: "title", type: "string" }],
178
+ });
179
+
180
+ const config: TypesenseConfig = {
181
+ collections: [
182
+ { name: "products", fields: [{ name: "title", type: "string" }] },
183
+ ],
184
+ overrides: [
185
+ {
186
+ id: "pin-featured",
187
+ collection: "products",
188
+ rule: { query: "featured", match: "exact" },
189
+ includes: [{ id: "product-1", position: 1 }],
190
+ },
191
+ ],
192
+ };
193
+
194
+ const plan = await buildPlan(config);
195
+ const ovChange = plan.changes.find(
196
+ (c) => c.identifier.type === "override"
197
+ );
198
+ expect(ovChange).toBeDefined();
199
+ expect(ovChange!.action).toBe("create");
200
+ });
201
+
202
+ test("plans creation for new API key", async () => {
203
+ const config: TypesenseConfig = {
204
+ apiKeys: [
205
+ {
206
+ description: "Search key",
207
+ actions: ["documents:search"],
208
+ collections: ["products"],
209
+ },
210
+ ],
211
+ };
212
+
213
+ const plan = await buildPlan(config);
214
+ const keyChange = plan.changes.find(
215
+ (c) => c.identifier.type === "apiKey"
216
+ );
217
+ expect(keyChange).toBeDefined();
218
+ expect(keyChange!.action).toBe("create");
219
+ });
220
+
221
+ test("generates correct summary counts", async () => {
222
+ await createCollection({
223
+ name: "existing",
224
+ fields: [{ name: "title", type: "string" }],
225
+ });
226
+
227
+ // Save state with an old resource that won't be in config
228
+ const state: State = {
229
+ version: "1.0",
230
+ resources: [
231
+ {
232
+ identifier: { type: "collection", name: "old" },
233
+ config: { name: "old", fields: [{ name: "x", type: "string" }] },
234
+ checksum: "abc",
235
+ lastUpdated: "",
236
+ },
237
+ ],
238
+ };
239
+ await saveState(state);
240
+
241
+ const config: TypesenseConfig = {
242
+ collections: [
243
+ { name: "existing", fields: [{ name: "title", type: "string" }] },
244
+ { name: "new-one", fields: [{ name: "y", type: "string" }] },
245
+ ],
246
+ };
247
+
248
+ const plan = await buildPlan(config);
249
+ expect(plan.summary.noChange).toBe(1); // existing
250
+ expect(plan.summary.create).toBe(1); // new-one
251
+ expect(plan.summary.delete).toBe(1); // old
252
+ });
253
+
254
+ test("plan with empty config and empty state has no changes", async () => {
255
+ const plan = await buildPlan({});
256
+ expect(plan.hasChanges).toBe(false);
257
+ expect(plan.summary.create).toBe(0);
258
+ expect(plan.summary.update).toBe(0);
259
+ expect(plan.summary.delete).toBe(0);
260
+ });
261
+ });
262
+
263
+ describe("buildNewState", () => {
264
+ test("builds state from config", () => {
265
+ const config: TypesenseConfig = {
266
+ collections: [
267
+ { name: "products", fields: [{ name: "title", type: "string" }] },
268
+ ],
269
+ aliases: [{ name: "products_live", collection: "products" }],
270
+ };
271
+
272
+ const currentState: State = { version: "1.0", resources: [] };
273
+ const newState = buildNewState(currentState, config);
274
+
275
+ expect(newState.resources).toHaveLength(2);
276
+ expect(newState.version).toBe("1.0");
277
+
278
+ const collectionResource = newState.resources.find(
279
+ (r) => r.identifier.type === "collection"
280
+ );
281
+ expect(collectionResource).toBeDefined();
282
+ expect(collectionResource!.identifier.name).toBe("products");
283
+ expect(collectionResource!.checksum).toBeDefined();
284
+
285
+ const aliasResource = newState.resources.find(
286
+ (r) => r.identifier.type === "alias"
287
+ );
288
+ expect(aliasResource).toBeDefined();
289
+ expect(aliasResource!.identifier.name).toBe("products_live");
290
+ });
291
+
292
+ test("builds state with all resource types", () => {
293
+ const config: TypesenseConfig = {
294
+ collections: [
295
+ { name: "products", fields: [{ name: "title", type: "string" }] },
296
+ ],
297
+ aliases: [{ name: "products_live", collection: "products" }],
298
+ synonyms: [
299
+ { id: "syn1", collection: "products", synonyms: ["a", "b"] },
300
+ ],
301
+ overrides: [
302
+ {
303
+ id: "ov1",
304
+ collection: "products",
305
+ rule: { query: "x", match: "exact" },
306
+ },
307
+ ],
308
+ apiKeys: [
309
+ { description: "key1", actions: ["*"], collections: ["*"] },
310
+ ],
311
+ };
312
+
313
+ const currentState: State = { version: "1.0", resources: [] };
314
+ const newState = buildNewState(currentState, config);
315
+
316
+ expect(newState.resources).toHaveLength(5);
317
+ const types = newState.resources.map((r) => r.identifier.type);
318
+ expect(types).toContain("collection");
319
+ expect(types).toContain("alias");
320
+ expect(types).toContain("synonym");
321
+ expect(types).toContain("override");
322
+ expect(types).toContain("apiKey");
323
+ });
324
+
325
+ test("preserves version from current state", () => {
326
+ const currentState: State = { version: "2.0", resources: [] };
327
+ const newState = buildNewState(currentState, {});
328
+ expect(newState.version).toBe("2.0");
329
+ });
330
+
331
+ test("builds empty state from empty config", () => {
332
+ const currentState: State = { version: "1.0", resources: [] };
333
+ const newState = buildNewState(currentState, {});
334
+ expect(newState.resources).toHaveLength(0);
335
+ });
336
+ });
337
+ });
@@ -0,0 +1,97 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { setupClient, cleanupTypesense } from "./setup.js";
3
+ import {
4
+ upsertPreset,
5
+ getPreset,
6
+ listPresets,
7
+ deletePreset,
8
+ presetConfigsEqual,
9
+ } from "../resources/preset.js";
10
+
11
+ describe("presets", () => {
12
+ beforeEach(async () => {
13
+ setupClient();
14
+ await cleanupTypesense();
15
+ });
16
+
17
+ afterEach(async () => {
18
+ await cleanupTypesense();
19
+ });
20
+
21
+ test("upsertPreset creates a preset", async () => {
22
+ await upsertPreset({
23
+ name: "listing_view",
24
+ value: {
25
+ searches: [
26
+ {
27
+ collection: "products",
28
+ q: "*",
29
+ sort_by: "popularity:desc",
30
+ },
31
+ ],
32
+ },
33
+ });
34
+
35
+ const result = await getPreset("listing_view");
36
+ expect(result).not.toBeNull();
37
+ expect(result!.name).toBe("listing_view");
38
+ expect(result!.value).toBeDefined();
39
+ });
40
+
41
+ test("getPreset returns null for non-existent", async () => {
42
+ const result = await getPreset("nonexistent");
43
+ expect(result).toBeNull();
44
+ });
45
+
46
+ test("listPresets returns all presets", async () => {
47
+ await upsertPreset({
48
+ name: "preset1",
49
+ value: { q: "*" },
50
+ });
51
+ await upsertPreset({
52
+ name: "preset2",
53
+ value: { q: "test" },
54
+ });
55
+
56
+ const presets = await listPresets();
57
+ const names = presets.map((p) => p.name);
58
+ expect(names).toContain("preset1");
59
+ expect(names).toContain("preset2");
60
+ });
61
+
62
+ test("upsertPreset updates existing preset", async () => {
63
+ await upsertPreset({
64
+ name: "my-preset",
65
+ value: { q: "old" },
66
+ });
67
+ await upsertPreset({
68
+ name: "my-preset",
69
+ value: { q: "new" },
70
+ });
71
+
72
+ const result = await getPreset("my-preset");
73
+ expect((result!.value as any).q).toBe("new");
74
+ });
75
+
76
+ test("deletePreset removes preset", async () => {
77
+ await upsertPreset({
78
+ name: "to-delete",
79
+ value: { q: "*" },
80
+ });
81
+ await deletePreset("to-delete");
82
+ const result = await getPreset("to-delete");
83
+ expect(result).toBeNull();
84
+ });
85
+
86
+ test("presetConfigsEqual compares correctly", () => {
87
+ const a = { name: "test", value: { q: "*", sort_by: "price:asc" } };
88
+ const b = { name: "test", value: { q: "*", sort_by: "price:asc" } };
89
+ expect(presetConfigsEqual(a, b)).toBe(true);
90
+ });
91
+
92
+ test("presetConfigsEqual detects differences", () => {
93
+ const a = { name: "test", value: { q: "*" } };
94
+ const b = { name: "test", value: { q: "different" } };
95
+ expect(presetConfigsEqual(a, b)).toBe(false);
96
+ });
97
+ });