@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,336 @@
1
+ import chalk from "chalk";
2
+ import ora from "ora";
3
+ import { loadState, saveState, formatResourceId } from "../state/index.js";
4
+ import {
5
+ createCollection,
6
+ updateCollection,
7
+ deleteCollection,
8
+ getCollection,
9
+ } from "../resources/collection.js";
10
+ import { upsertAlias, deleteAlias } from "../resources/alias.js";
11
+ import { upsertSynonym, deleteSynonym } from "../resources/synonym.js";
12
+ import {
13
+ createSynonymSet,
14
+ updateSynonymSet,
15
+ deleteSynonymSet,
16
+ getSynonymSet,
17
+ } from "../resources/synonymset.js";
18
+ import { upsertOverride, deleteOverride } from "../resources/override.js";
19
+ import {
20
+ createCurationSet,
21
+ updateCurationSet,
22
+ deleteCurationSet,
23
+ getCurationSet,
24
+ } from "../resources/curationset.js";
25
+ import {
26
+ createAnalyticsRule,
27
+ updateAnalyticsRule,
28
+ deleteAnalyticsRule,
29
+ } from "../resources/analyticsrule.js";
30
+ import {
31
+ createApiKey,
32
+ deleteApiKeyByDescription,
33
+ getApiKey,
34
+ } from "../resources/apikey.js";
35
+ import {
36
+ upsertStopwordSet,
37
+ deleteStopwordSet,
38
+ } from "../resources/stopword.js";
39
+ import {
40
+ upsertPreset,
41
+ deletePreset,
42
+ } from "../resources/preset.js";
43
+ import {
44
+ upsertStemmingDictionary,
45
+ } from "../resources/stemmingdictionary.js";
46
+ import { buildNewState } from "../plan/index.js";
47
+ import type {
48
+ Plan,
49
+ ResourceChange,
50
+ TypesenseConfig,
51
+ CollectionConfig,
52
+ AliasConfig,
53
+ SynonymConfig,
54
+ SynonymSetConfig,
55
+ OverrideConfig,
56
+ CurationSetConfig,
57
+ AnalyticsRuleConfig,
58
+ ApiKeyConfig,
59
+ StopwordSetConfig,
60
+ PresetConfig,
61
+ StemmingDictionaryConfig,
62
+ } from "../types/index.js";
63
+
64
+ export interface ApplyResult {
65
+ success: boolean;
66
+ applied: ResourceChange[];
67
+ failed: Array<{ change: ResourceChange; error: Error }>;
68
+ }
69
+
70
+ /**
71
+ * Apply a single resource change
72
+ */
73
+ async function applyChange(change: ResourceChange): Promise<void> {
74
+ const { action, identifier } = change;
75
+
76
+ switch (identifier.type) {
77
+ case "collection": {
78
+ if (action === "create") {
79
+ await createCollection(change.after as CollectionConfig);
80
+ } else if (action === "update") {
81
+ const existing = await getCollection(identifier.name);
82
+ if (!existing) {
83
+ throw new Error(`Collection ${identifier.name} not found for update`);
84
+ }
85
+ // Update handles field modifications by dropping and re-adding
86
+ await updateCollection(change.after as CollectionConfig, existing);
87
+ } else if (action === "delete") {
88
+ await deleteCollection(identifier.name);
89
+ }
90
+ break;
91
+ }
92
+
93
+ case "alias": {
94
+ if (action === "create" || action === "update") {
95
+ await upsertAlias(change.after as AliasConfig);
96
+ } else if (action === "delete") {
97
+ await deleteAlias(identifier.name);
98
+ }
99
+ break;
100
+ }
101
+
102
+ case "synonym": {
103
+ if (action === "create" || action === "update") {
104
+ await upsertSynonym(change.after as SynonymConfig);
105
+ } else if (action === "delete") {
106
+ await deleteSynonym(identifier.name, identifier.collection!);
107
+ }
108
+ break;
109
+ }
110
+
111
+ case "synonymSet": {
112
+ if (action === "create") {
113
+ await createSynonymSet(change.after as SynonymSetConfig);
114
+ } else if (action === "update") {
115
+ const existing = await getSynonymSet(identifier.name);
116
+ if (existing) {
117
+ await updateSynonymSet(change.after as SynonymSetConfig, existing);
118
+ }
119
+ } else if (action === "delete") {
120
+ await deleteSynonymSet(identifier.name);
121
+ }
122
+ break;
123
+ }
124
+
125
+ case "override": {
126
+ if (action === "create" || action === "update") {
127
+ await upsertOverride(change.after as OverrideConfig);
128
+ } else if (action === "delete") {
129
+ await deleteOverride(identifier.name, identifier.collection!);
130
+ }
131
+ break;
132
+ }
133
+
134
+ case "analyticsRule": {
135
+ if (action === "create") {
136
+ await createAnalyticsRule(change.after as AnalyticsRuleConfig);
137
+ } else if (action === "update") {
138
+ await updateAnalyticsRule(change.after as AnalyticsRuleConfig);
139
+ } else if (action === "delete") {
140
+ await deleteAnalyticsRule(identifier.name);
141
+ }
142
+ break;
143
+ }
144
+
145
+ case "apiKey": {
146
+ if (action === "create") {
147
+ const apiKeyConfig = change.after as ApiKeyConfig;
148
+ const result = await createApiKey(apiKeyConfig);
149
+ console.log(
150
+ chalk.cyan(
151
+ `\n API Key created! Save this value (shown only once):\n ${result.value}\n`
152
+ )
153
+ );
154
+ } else if (action === "update") {
155
+ // API keys can't be updated, so delete and recreate
156
+ const existing = await getApiKey(identifier.name);
157
+ if (existing) {
158
+ await deleteApiKeyByDescription(identifier.name);
159
+ }
160
+ const apiKeyConfig = change.after as ApiKeyConfig;
161
+ const result = await createApiKey(apiKeyConfig);
162
+ console.log(
163
+ chalk.cyan(
164
+ `\n API Key recreated! Save this value (shown only once):\n ${result.value}\n`
165
+ )
166
+ );
167
+ } else if (action === "delete") {
168
+ await deleteApiKeyByDescription(identifier.name);
169
+ }
170
+ break;
171
+ }
172
+
173
+ case "curationSet": {
174
+ if (action === "create") {
175
+ await createCurationSet(change.after as CurationSetConfig);
176
+ } else if (action === "update") {
177
+ const existing = await getCurationSet(identifier.name);
178
+ if (existing) {
179
+ await updateCurationSet(change.after as CurationSetConfig, existing);
180
+ }
181
+ } else if (action === "delete") {
182
+ await deleteCurationSet(identifier.name);
183
+ }
184
+ break;
185
+ }
186
+
187
+ case "stopword": {
188
+ if (action === "create" || action === "update") {
189
+ await upsertStopwordSet(change.after as StopwordSetConfig);
190
+ } else if (action === "delete") {
191
+ await deleteStopwordSet(identifier.name);
192
+ }
193
+ break;
194
+ }
195
+
196
+ case "preset": {
197
+ if (action === "create" || action === "update") {
198
+ await upsertPreset(change.after as PresetConfig);
199
+ } else if (action === "delete") {
200
+ await deletePreset(identifier.name);
201
+ }
202
+ break;
203
+ }
204
+
205
+ case "stemmingDictionary": {
206
+ if (action === "create" || action === "update") {
207
+ await upsertStemmingDictionary(change.after as StemmingDictionaryConfig);
208
+ }
209
+ // Note: Typesense does not support deleting stemming dictionaries via API
210
+ break;
211
+ }
212
+
213
+ default:
214
+ throw new Error(`Unknown resource type: ${identifier.type}`);
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Apply a plan to Typesense
220
+ */
221
+ export async function applyPlan(
222
+ plan: Plan,
223
+ config: TypesenseConfig,
224
+ options: {
225
+ autoApprove?: boolean;
226
+ forceRecreate?: boolean;
227
+ } = {}
228
+ ): Promise<ApplyResult> {
229
+ const result: ApplyResult = {
230
+ success: true,
231
+ applied: [],
232
+ failed: [],
233
+ };
234
+
235
+ // Filter to only changes that need to be applied
236
+ const changesToApply = plan.changes.filter((c) => c.action !== "no-change");
237
+
238
+ if (changesToApply.length === 0) {
239
+ console.log(chalk.green("\nNo changes to apply."));
240
+ return result;
241
+ }
242
+
243
+ // Resource type priority for ordering (lower = first)
244
+ // SynonymSets must be created before collections that reference them
245
+ const createOrder: Record<string, number> = {
246
+ stemmingDictionary: 0,
247
+ synonymSet: 1,
248
+ curationSet: 2,
249
+ stopword: 3,
250
+ preset: 4,
251
+ collection: 5,
252
+ alias: 6,
253
+ synonym: 7,
254
+ override: 8,
255
+ analyticsRule: 9,
256
+ apiKey: 10,
257
+ };
258
+ // For deletes, reverse the order
259
+ const deleteOrder: Record<string, number> = {
260
+ apiKey: 0,
261
+ analyticsRule: 1,
262
+ override: 2,
263
+ synonym: 3,
264
+ alias: 4,
265
+ collection: 5,
266
+ preset: 6,
267
+ stopword: 7,
268
+ curationSet: 8,
269
+ synonymSet: 9,
270
+ stemmingDictionary: 10,
271
+ };
272
+
273
+ const sortByType = (a: ResourceChange, b: ResourceChange, order: Record<string, number>) => {
274
+ return (order[a.identifier.type] ?? 99) - (order[b.identifier.type] ?? 99);
275
+ };
276
+
277
+ // Apply changes in order: creates first (with dependency order), then updates, then deletes
278
+ const creates = changesToApply
279
+ .filter((c) => c.action === "create")
280
+ .sort((a, b) => sortByType(a, b, createOrder));
281
+ const updates = changesToApply
282
+ .filter((c) => c.action === "update")
283
+ .sort((a, b) => sortByType(a, b, createOrder));
284
+ const deletes = changesToApply
285
+ .filter((c) => c.action === "delete")
286
+ .sort((a, b) => sortByType(a, b, deleteOrder));
287
+
288
+ const orderedChanges = [...creates, ...updates, ...deletes];
289
+
290
+ console.log(chalk.bold(`\nApplying ${orderedChanges.length} changes...\n`));
291
+
292
+ for (const change of orderedChanges) {
293
+ const resourceId = formatResourceId(change.identifier);
294
+ const actionSymbol =
295
+ change.action === "create"
296
+ ? chalk.green("+")
297
+ : change.action === "update"
298
+ ? chalk.yellow("~")
299
+ : chalk.red("-");
300
+
301
+ const spinner = ora(`${actionSymbol} ${resourceId}`).start();
302
+
303
+ try {
304
+ await applyChange(change);
305
+ spinner.succeed(`${actionSymbol} ${resourceId}`);
306
+ result.applied.push(change);
307
+ } catch (error) {
308
+ spinner.fail(`${actionSymbol} ${resourceId}`);
309
+ console.error(
310
+ chalk.red(` Error: ${error instanceof Error ? error.message : String(error)}`)
311
+ );
312
+ result.failed.push({
313
+ change,
314
+ error: error instanceof Error ? error : new Error(String(error)),
315
+ });
316
+ result.success = false;
317
+ }
318
+ }
319
+
320
+ // Update state if any changes were applied
321
+ if (result.applied.length > 0) {
322
+ const currentState = await loadState();
323
+ const newState = buildNewState(currentState, config);
324
+ await saveState(newState);
325
+ console.log(chalk.gray("\nState saved."));
326
+ }
327
+
328
+ // Print summary
329
+ console.log(chalk.bold("\nApply complete:"));
330
+ console.log(` ${chalk.green(`${result.applied.length} applied`)}`);
331
+ if (result.failed.length > 0) {
332
+ console.log(` ${chalk.red(`${result.failed.length} failed`)}`);
333
+ }
334
+
335
+ return result;
336
+ }