@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,1333 @@
|
|
|
1
|
+
import { diffJson } from "diff";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import {
|
|
4
|
+
loadState,
|
|
5
|
+
computeChecksum,
|
|
6
|
+
formatResourceId,
|
|
7
|
+
} from "../state/index.js";
|
|
8
|
+
import {
|
|
9
|
+
getCollection,
|
|
10
|
+
listCollections,
|
|
11
|
+
getAlias,
|
|
12
|
+
listAliases,
|
|
13
|
+
getSynonym,
|
|
14
|
+
listAllSynonyms,
|
|
15
|
+
getSynonymSet,
|
|
16
|
+
listSynonymSets,
|
|
17
|
+
synonymSetConfigsEqual,
|
|
18
|
+
getOverride,
|
|
19
|
+
listAllOverrides,
|
|
20
|
+
getCurationSet,
|
|
21
|
+
listCurationSets,
|
|
22
|
+
curationSetConfigsEqual,
|
|
23
|
+
getAnalyticsRule,
|
|
24
|
+
listAnalyticsRules,
|
|
25
|
+
analyticsRuleConfigsEqual,
|
|
26
|
+
getApiKey,
|
|
27
|
+
listApiKeys,
|
|
28
|
+
apiKeyConfigsEqual,
|
|
29
|
+
getStopwordSet,
|
|
30
|
+
listStopwordSets,
|
|
31
|
+
stopwordSetConfigsEqual,
|
|
32
|
+
getPreset,
|
|
33
|
+
listPresets,
|
|
34
|
+
presetConfigsEqual,
|
|
35
|
+
getStemmingDictionary,
|
|
36
|
+
listStemmingDictionaries,
|
|
37
|
+
stemmingDictionaryConfigsEqual,
|
|
38
|
+
type StoredApiKey,
|
|
39
|
+
} from "../resources/index.js";
|
|
40
|
+
import type {
|
|
41
|
+
TypesenseConfig,
|
|
42
|
+
Plan,
|
|
43
|
+
ResourceChange,
|
|
44
|
+
ResourceIdentifier,
|
|
45
|
+
CollectionConfig,
|
|
46
|
+
AliasConfig,
|
|
47
|
+
SynonymConfig,
|
|
48
|
+
SynonymSetConfig,
|
|
49
|
+
OverrideConfig,
|
|
50
|
+
CurationSetConfig,
|
|
51
|
+
AnalyticsRuleConfig,
|
|
52
|
+
ApiKeyConfig,
|
|
53
|
+
StopwordSetConfig,
|
|
54
|
+
PresetConfig,
|
|
55
|
+
StemmingDictionaryConfig,
|
|
56
|
+
ManagedResource,
|
|
57
|
+
State,
|
|
58
|
+
} from "../types/index.js";
|
|
59
|
+
|
|
60
|
+
// Re-export for convenience
|
|
61
|
+
export { formatResourceId } from "../state/index.js";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Normalize a config object for comparison
|
|
65
|
+
* Removes undefined values and sorts keys
|
|
66
|
+
*/
|
|
67
|
+
function normalizeConfig<T extends object>(config: T): T {
|
|
68
|
+
const sorted = Object.keys(config)
|
|
69
|
+
.sort()
|
|
70
|
+
.reduce((acc, key) => {
|
|
71
|
+
const value = config[key as keyof T];
|
|
72
|
+
if (value !== undefined) {
|
|
73
|
+
if (Array.isArray(value)) {
|
|
74
|
+
acc[key as keyof T] = value.map((item) =>
|
|
75
|
+
typeof item === "object" && item !== null
|
|
76
|
+
? normalizeConfig(item)
|
|
77
|
+
: item
|
|
78
|
+
) as T[keyof T];
|
|
79
|
+
} else if (typeof value === "object" && value !== null) {
|
|
80
|
+
acc[key as keyof T] = normalizeConfig(value as object) as T[keyof T];
|
|
81
|
+
} else {
|
|
82
|
+
acc[key as keyof T] = value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return acc;
|
|
86
|
+
}, {} as T);
|
|
87
|
+
return sorted;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Compare two configs and check if they are equal
|
|
92
|
+
*/
|
|
93
|
+
function configsEqual(a: unknown, b: unknown): boolean {
|
|
94
|
+
const normalizedA = normalizeConfig(a as object);
|
|
95
|
+
const normalizedB = normalizeConfig(b as object);
|
|
96
|
+
return JSON.stringify(normalizedA) === JSON.stringify(normalizedB);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check if a string value appears to be masked (contains consecutive asterisks)
|
|
101
|
+
* Typesense masks sensitive fields like api_key with patterns like "sk-pr****..."
|
|
102
|
+
*/
|
|
103
|
+
function isMaskedValue(value: unknown): boolean {
|
|
104
|
+
if (typeof value !== "string") return false;
|
|
105
|
+
// Matches patterns like "sk-pr****" - contains 3+ consecutive asterisks
|
|
106
|
+
return /\*{3,}/.test(value);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Find matching item in local array by name property
|
|
111
|
+
*/
|
|
112
|
+
function findMatchingLocalItem(
|
|
113
|
+
remoteItem: Record<string, unknown>,
|
|
114
|
+
localArray: unknown[]
|
|
115
|
+
): Record<string, unknown> | undefined {
|
|
116
|
+
// Match by 'name' property (used in fields array)
|
|
117
|
+
if ("name" in remoteItem) {
|
|
118
|
+
return localArray.find(
|
|
119
|
+
(item) =>
|
|
120
|
+
typeof item === "object" &&
|
|
121
|
+
item !== null &&
|
|
122
|
+
"name" in item &&
|
|
123
|
+
(item as Record<string, unknown>).name === remoteItem.name
|
|
124
|
+
) as Record<string, unknown> | undefined;
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Recursively normalize remote config for comparison:
|
|
131
|
+
* 1. Replace masked values with corresponding local values
|
|
132
|
+
* 2. Strip fields from remote that don't exist in local (computed/default fields)
|
|
133
|
+
*/
|
|
134
|
+
function normalizeRemoteForComparison<T extends object>(remote: T, local: T): T {
|
|
135
|
+
const result = {} as T;
|
|
136
|
+
|
|
137
|
+
// Only include keys that exist in local config
|
|
138
|
+
for (const key of Object.keys(local) as Array<keyof T>) {
|
|
139
|
+
const remoteValue = remote[key];
|
|
140
|
+
const localValue = local[key];
|
|
141
|
+
|
|
142
|
+
// Skip if remote doesn't have this key
|
|
143
|
+
if (remoteValue === undefined) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (isMaskedValue(remoteValue)) {
|
|
148
|
+
// Remote is masked, use local value
|
|
149
|
+
result[key] = localValue;
|
|
150
|
+
} else if (
|
|
151
|
+
typeof remoteValue === "object" &&
|
|
152
|
+
remoteValue !== null &&
|
|
153
|
+
typeof localValue === "object" &&
|
|
154
|
+
localValue !== null &&
|
|
155
|
+
!Array.isArray(remoteValue)
|
|
156
|
+
) {
|
|
157
|
+
// Recursively handle nested objects
|
|
158
|
+
result[key] = normalizeRemoteForComparison(
|
|
159
|
+
remoteValue as object,
|
|
160
|
+
localValue as object
|
|
161
|
+
) as T[keyof T];
|
|
162
|
+
} else if (Array.isArray(remoteValue) && Array.isArray(localValue)) {
|
|
163
|
+
// Handle arrays (like fields array) - reorder to match local order
|
|
164
|
+
// and normalize each matching item
|
|
165
|
+
result[key] = localValue.map((localItem) => {
|
|
166
|
+
if (typeof localItem === "object" && localItem !== null) {
|
|
167
|
+
const matchingRemote = findMatchingLocalItem(
|
|
168
|
+
localItem as Record<string, unknown>,
|
|
169
|
+
remoteValue
|
|
170
|
+
);
|
|
171
|
+
if (matchingRemote) {
|
|
172
|
+
return normalizeRemoteForComparison(
|
|
173
|
+
matchingRemote,
|
|
174
|
+
localItem as Record<string, unknown>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return localItem;
|
|
179
|
+
}) as T[keyof T];
|
|
180
|
+
} else {
|
|
181
|
+
result[key] = remoteValue;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Generate a human-readable diff between two configs
|
|
190
|
+
* Only shows added and removed lines, not unchanged context
|
|
191
|
+
*/
|
|
192
|
+
function generateDiff(before: unknown, after: unknown): string {
|
|
193
|
+
const normalizedBefore = normalizeConfig((before || {}) as object);
|
|
194
|
+
const normalizedAfter = normalizeConfig((after || {}) as object);
|
|
195
|
+
|
|
196
|
+
const changes = diffJson(normalizedBefore, normalizedAfter);
|
|
197
|
+
|
|
198
|
+
let result = "";
|
|
199
|
+
for (const part of changes) {
|
|
200
|
+
// Skip unchanged lines to keep diff concise
|
|
201
|
+
if (!part.added && !part.removed) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const color = part.added ? chalk.green : chalk.red;
|
|
206
|
+
const prefix = part.added ? "+ " : "- ";
|
|
207
|
+
|
|
208
|
+
const lines = part.value.split("\n").filter((line) => line.trim());
|
|
209
|
+
for (const line of lines) {
|
|
210
|
+
result += color(`${prefix}${line}\n`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Build a plan comparing desired config to actual state
|
|
219
|
+
*/
|
|
220
|
+
export async function buildPlan(config: TypesenseConfig): Promise<Plan> {
|
|
221
|
+
const state = await loadState();
|
|
222
|
+
const changes: ResourceChange[] = [];
|
|
223
|
+
|
|
224
|
+
// Track what's in the desired config
|
|
225
|
+
const desiredResources = new Set<string>();
|
|
226
|
+
|
|
227
|
+
// Plan collections
|
|
228
|
+
if (config.collections) {
|
|
229
|
+
for (const collectionConfig of config.collections) {
|
|
230
|
+
const identifier: ResourceIdentifier = {
|
|
231
|
+
type: "collection",
|
|
232
|
+
name: collectionConfig.name,
|
|
233
|
+
};
|
|
234
|
+
const resourceId = formatResourceId(identifier);
|
|
235
|
+
desiredResources.add(resourceId);
|
|
236
|
+
|
|
237
|
+
const existing = await getCollection(collectionConfig.name);
|
|
238
|
+
|
|
239
|
+
if (!existing) {
|
|
240
|
+
// Create
|
|
241
|
+
changes.push({
|
|
242
|
+
action: "create",
|
|
243
|
+
identifier,
|
|
244
|
+
after: collectionConfig,
|
|
245
|
+
diff: generateDiff(null, collectionConfig),
|
|
246
|
+
});
|
|
247
|
+
} else {
|
|
248
|
+
// Normalize remote: replace masked values and strip computed/default fields
|
|
249
|
+
const existingForComparison = normalizeRemoteForComparison(existing, collectionConfig);
|
|
250
|
+
|
|
251
|
+
if (!configsEqual(existingForComparison, collectionConfig)) {
|
|
252
|
+
// Update
|
|
253
|
+
changes.push({
|
|
254
|
+
action: "update",
|
|
255
|
+
identifier,
|
|
256
|
+
before: existing,
|
|
257
|
+
after: collectionConfig,
|
|
258
|
+
diff: generateDiff(existingForComparison, collectionConfig),
|
|
259
|
+
});
|
|
260
|
+
} else {
|
|
261
|
+
// No change
|
|
262
|
+
changes.push({
|
|
263
|
+
action: "no-change",
|
|
264
|
+
identifier,
|
|
265
|
+
before: existing,
|
|
266
|
+
after: collectionConfig,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Plan aliases
|
|
274
|
+
if (config.aliases) {
|
|
275
|
+
for (const aliasConfig of config.aliases) {
|
|
276
|
+
const identifier: ResourceIdentifier = {
|
|
277
|
+
type: "alias",
|
|
278
|
+
name: aliasConfig.name,
|
|
279
|
+
};
|
|
280
|
+
const resourceId = formatResourceId(identifier);
|
|
281
|
+
desiredResources.add(resourceId);
|
|
282
|
+
|
|
283
|
+
const existing = await getAlias(aliasConfig.name);
|
|
284
|
+
|
|
285
|
+
if (!existing) {
|
|
286
|
+
// Create
|
|
287
|
+
changes.push({
|
|
288
|
+
action: "create",
|
|
289
|
+
identifier,
|
|
290
|
+
after: aliasConfig,
|
|
291
|
+
diff: generateDiff(null, aliasConfig),
|
|
292
|
+
});
|
|
293
|
+
} else if (!configsEqual(existing, aliasConfig)) {
|
|
294
|
+
// Update
|
|
295
|
+
changes.push({
|
|
296
|
+
action: "update",
|
|
297
|
+
identifier,
|
|
298
|
+
before: existing,
|
|
299
|
+
after: aliasConfig,
|
|
300
|
+
diff: generateDiff(existing, aliasConfig),
|
|
301
|
+
});
|
|
302
|
+
} else {
|
|
303
|
+
// No change
|
|
304
|
+
changes.push({
|
|
305
|
+
action: "no-change",
|
|
306
|
+
identifier,
|
|
307
|
+
before: existing,
|
|
308
|
+
after: aliasConfig,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Plan synonyms
|
|
315
|
+
if (config.synonyms) {
|
|
316
|
+
for (const synonymConfig of config.synonyms) {
|
|
317
|
+
const identifier: ResourceIdentifier = {
|
|
318
|
+
type: "synonym",
|
|
319
|
+
name: synonymConfig.id,
|
|
320
|
+
collection: synonymConfig.collection,
|
|
321
|
+
};
|
|
322
|
+
const resourceId = formatResourceId(identifier);
|
|
323
|
+
desiredResources.add(resourceId);
|
|
324
|
+
|
|
325
|
+
const existing = await getSynonym(synonymConfig.id, synonymConfig.collection);
|
|
326
|
+
|
|
327
|
+
if (!existing) {
|
|
328
|
+
// Create
|
|
329
|
+
changes.push({
|
|
330
|
+
action: "create",
|
|
331
|
+
identifier,
|
|
332
|
+
after: synonymConfig,
|
|
333
|
+
diff: generateDiff(null, synonymConfig),
|
|
334
|
+
});
|
|
335
|
+
} else if (!configsEqual(existing, synonymConfig)) {
|
|
336
|
+
// Update
|
|
337
|
+
changes.push({
|
|
338
|
+
action: "update",
|
|
339
|
+
identifier,
|
|
340
|
+
before: existing,
|
|
341
|
+
after: synonymConfig,
|
|
342
|
+
diff: generateDiff(existing, synonymConfig),
|
|
343
|
+
});
|
|
344
|
+
} else {
|
|
345
|
+
// No change
|
|
346
|
+
changes.push({
|
|
347
|
+
action: "no-change",
|
|
348
|
+
identifier,
|
|
349
|
+
before: existing,
|
|
350
|
+
after: synonymConfig,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Plan synonym sets (Typesense 30.0+)
|
|
357
|
+
if (config.synonymSets) {
|
|
358
|
+
for (const synonymSetConfig of config.synonymSets) {
|
|
359
|
+
const identifier: ResourceIdentifier = {
|
|
360
|
+
type: "synonymSet",
|
|
361
|
+
name: synonymSetConfig.name,
|
|
362
|
+
};
|
|
363
|
+
const resourceId = formatResourceId(identifier);
|
|
364
|
+
desiredResources.add(resourceId);
|
|
365
|
+
|
|
366
|
+
const existing = await getSynonymSet(synonymSetConfig.name);
|
|
367
|
+
|
|
368
|
+
if (!existing) {
|
|
369
|
+
// Create
|
|
370
|
+
changes.push({
|
|
371
|
+
action: "create",
|
|
372
|
+
identifier,
|
|
373
|
+
after: synonymSetConfig,
|
|
374
|
+
diff: generateDiff(null, synonymSetConfig),
|
|
375
|
+
});
|
|
376
|
+
} else if (!synonymSetConfigsEqual(existing, synonymSetConfig)) {
|
|
377
|
+
// Update
|
|
378
|
+
changes.push({
|
|
379
|
+
action: "update",
|
|
380
|
+
identifier,
|
|
381
|
+
before: existing,
|
|
382
|
+
after: synonymSetConfig,
|
|
383
|
+
diff: generateDiff(existing, synonymSetConfig),
|
|
384
|
+
});
|
|
385
|
+
} else {
|
|
386
|
+
// No change
|
|
387
|
+
changes.push({
|
|
388
|
+
action: "no-change",
|
|
389
|
+
identifier,
|
|
390
|
+
before: existing,
|
|
391
|
+
after: synonymSetConfig,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Plan overrides
|
|
398
|
+
if (config.overrides) {
|
|
399
|
+
for (const overrideConfig of config.overrides) {
|
|
400
|
+
const identifier: ResourceIdentifier = {
|
|
401
|
+
type: "override",
|
|
402
|
+
name: overrideConfig.id,
|
|
403
|
+
collection: overrideConfig.collection,
|
|
404
|
+
};
|
|
405
|
+
const resourceId = formatResourceId(identifier);
|
|
406
|
+
desiredResources.add(resourceId);
|
|
407
|
+
|
|
408
|
+
const existing = await getOverride(overrideConfig.id, overrideConfig.collection);
|
|
409
|
+
|
|
410
|
+
if (!existing) {
|
|
411
|
+
// Create
|
|
412
|
+
changes.push({
|
|
413
|
+
action: "create",
|
|
414
|
+
identifier,
|
|
415
|
+
after: overrideConfig,
|
|
416
|
+
diff: generateDiff(null, overrideConfig),
|
|
417
|
+
});
|
|
418
|
+
} else if (!configsEqual(existing, overrideConfig)) {
|
|
419
|
+
// Update
|
|
420
|
+
changes.push({
|
|
421
|
+
action: "update",
|
|
422
|
+
identifier,
|
|
423
|
+
before: existing,
|
|
424
|
+
after: overrideConfig,
|
|
425
|
+
diff: generateDiff(existing, overrideConfig),
|
|
426
|
+
});
|
|
427
|
+
} else {
|
|
428
|
+
// No change
|
|
429
|
+
changes.push({
|
|
430
|
+
action: "no-change",
|
|
431
|
+
identifier,
|
|
432
|
+
before: existing,
|
|
433
|
+
after: overrideConfig,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Plan analytics rules
|
|
440
|
+
if (config.analyticsRules) {
|
|
441
|
+
for (const analyticsRuleConfig of config.analyticsRules) {
|
|
442
|
+
const identifier: ResourceIdentifier = {
|
|
443
|
+
type: "analyticsRule",
|
|
444
|
+
name: analyticsRuleConfig.name,
|
|
445
|
+
};
|
|
446
|
+
const resourceId = formatResourceId(identifier);
|
|
447
|
+
desiredResources.add(resourceId);
|
|
448
|
+
|
|
449
|
+
const existing = await getAnalyticsRule(analyticsRuleConfig.name);
|
|
450
|
+
|
|
451
|
+
if (!existing) {
|
|
452
|
+
// Create
|
|
453
|
+
changes.push({
|
|
454
|
+
action: "create",
|
|
455
|
+
identifier,
|
|
456
|
+
after: analyticsRuleConfig,
|
|
457
|
+
diff: generateDiff(null, analyticsRuleConfig),
|
|
458
|
+
});
|
|
459
|
+
} else if (!analyticsRuleConfigsEqual(existing, analyticsRuleConfig)) {
|
|
460
|
+
// Update
|
|
461
|
+
changes.push({
|
|
462
|
+
action: "update",
|
|
463
|
+
identifier,
|
|
464
|
+
before: existing,
|
|
465
|
+
after: analyticsRuleConfig,
|
|
466
|
+
diff: generateDiff(existing, analyticsRuleConfig),
|
|
467
|
+
});
|
|
468
|
+
} else {
|
|
469
|
+
// No change
|
|
470
|
+
changes.push({
|
|
471
|
+
action: "no-change",
|
|
472
|
+
identifier,
|
|
473
|
+
before: existing,
|
|
474
|
+
after: analyticsRuleConfig,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Plan API keys (using description as identifier)
|
|
481
|
+
if (config.apiKeys) {
|
|
482
|
+
for (const apiKeyConfig of config.apiKeys) {
|
|
483
|
+
const identifier: ResourceIdentifier = {
|
|
484
|
+
type: "apiKey",
|
|
485
|
+
name: apiKeyConfig.description,
|
|
486
|
+
};
|
|
487
|
+
const resourceId = formatResourceId(identifier);
|
|
488
|
+
desiredResources.add(resourceId);
|
|
489
|
+
|
|
490
|
+
const existing = await getApiKey(apiKeyConfig.description);
|
|
491
|
+
|
|
492
|
+
if (!existing) {
|
|
493
|
+
// Create
|
|
494
|
+
changes.push({
|
|
495
|
+
action: "create",
|
|
496
|
+
identifier,
|
|
497
|
+
after: apiKeyConfig,
|
|
498
|
+
diff: generateDiff(null, apiKeyConfig),
|
|
499
|
+
});
|
|
500
|
+
} else if (!apiKeyConfigsEqual(existing, apiKeyConfig)) {
|
|
501
|
+
// Update (requires delete + create since API keys can't be updated)
|
|
502
|
+
changes.push({
|
|
503
|
+
action: "update",
|
|
504
|
+
identifier,
|
|
505
|
+
before: existing,
|
|
506
|
+
after: apiKeyConfig,
|
|
507
|
+
diff: generateDiff(existing, apiKeyConfig),
|
|
508
|
+
});
|
|
509
|
+
} else {
|
|
510
|
+
// No change
|
|
511
|
+
changes.push({
|
|
512
|
+
action: "no-change",
|
|
513
|
+
identifier,
|
|
514
|
+
before: existing,
|
|
515
|
+
after: apiKeyConfig,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Plan curation sets (Typesense 30.0+)
|
|
522
|
+
if (config.curationSets) {
|
|
523
|
+
for (const curationSetConfig of config.curationSets) {
|
|
524
|
+
const identifier: ResourceIdentifier = {
|
|
525
|
+
type: "curationSet",
|
|
526
|
+
name: curationSetConfig.name,
|
|
527
|
+
};
|
|
528
|
+
const resourceId = formatResourceId(identifier);
|
|
529
|
+
desiredResources.add(resourceId);
|
|
530
|
+
|
|
531
|
+
const existing = await getCurationSet(curationSetConfig.name);
|
|
532
|
+
|
|
533
|
+
if (!existing) {
|
|
534
|
+
changes.push({
|
|
535
|
+
action: "create",
|
|
536
|
+
identifier,
|
|
537
|
+
after: curationSetConfig,
|
|
538
|
+
diff: generateDiff(null, curationSetConfig),
|
|
539
|
+
});
|
|
540
|
+
} else if (!curationSetConfigsEqual(existing, curationSetConfig)) {
|
|
541
|
+
changes.push({
|
|
542
|
+
action: "update",
|
|
543
|
+
identifier,
|
|
544
|
+
before: existing,
|
|
545
|
+
after: curationSetConfig,
|
|
546
|
+
diff: generateDiff(existing, curationSetConfig),
|
|
547
|
+
});
|
|
548
|
+
} else {
|
|
549
|
+
changes.push({
|
|
550
|
+
action: "no-change",
|
|
551
|
+
identifier,
|
|
552
|
+
before: existing,
|
|
553
|
+
after: curationSetConfig,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Plan stopwords
|
|
560
|
+
if (config.stopwords) {
|
|
561
|
+
for (const stopwordConfig of config.stopwords) {
|
|
562
|
+
const identifier: ResourceIdentifier = {
|
|
563
|
+
type: "stopword",
|
|
564
|
+
name: stopwordConfig.id,
|
|
565
|
+
};
|
|
566
|
+
const resourceId = formatResourceId(identifier);
|
|
567
|
+
desiredResources.add(resourceId);
|
|
568
|
+
|
|
569
|
+
const existing = await getStopwordSet(stopwordConfig.id);
|
|
570
|
+
|
|
571
|
+
if (!existing) {
|
|
572
|
+
changes.push({
|
|
573
|
+
action: "create",
|
|
574
|
+
identifier,
|
|
575
|
+
after: stopwordConfig,
|
|
576
|
+
diff: generateDiff(null, stopwordConfig),
|
|
577
|
+
});
|
|
578
|
+
} else if (!stopwordSetConfigsEqual(existing, stopwordConfig)) {
|
|
579
|
+
changes.push({
|
|
580
|
+
action: "update",
|
|
581
|
+
identifier,
|
|
582
|
+
before: existing,
|
|
583
|
+
after: stopwordConfig,
|
|
584
|
+
diff: generateDiff(existing, stopwordConfig),
|
|
585
|
+
});
|
|
586
|
+
} else {
|
|
587
|
+
changes.push({
|
|
588
|
+
action: "no-change",
|
|
589
|
+
identifier,
|
|
590
|
+
before: existing,
|
|
591
|
+
after: stopwordConfig,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Plan presets
|
|
598
|
+
if (config.presets) {
|
|
599
|
+
for (const presetConfig of config.presets) {
|
|
600
|
+
const identifier: ResourceIdentifier = {
|
|
601
|
+
type: "preset",
|
|
602
|
+
name: presetConfig.name,
|
|
603
|
+
};
|
|
604
|
+
const resourceId = formatResourceId(identifier);
|
|
605
|
+
desiredResources.add(resourceId);
|
|
606
|
+
|
|
607
|
+
const existing = await getPreset(presetConfig.name);
|
|
608
|
+
|
|
609
|
+
if (!existing) {
|
|
610
|
+
changes.push({
|
|
611
|
+
action: "create",
|
|
612
|
+
identifier,
|
|
613
|
+
after: presetConfig,
|
|
614
|
+
diff: generateDiff(null, presetConfig),
|
|
615
|
+
});
|
|
616
|
+
} else if (!presetConfigsEqual(existing, presetConfig)) {
|
|
617
|
+
changes.push({
|
|
618
|
+
action: "update",
|
|
619
|
+
identifier,
|
|
620
|
+
before: existing,
|
|
621
|
+
after: presetConfig,
|
|
622
|
+
diff: generateDiff(existing, presetConfig),
|
|
623
|
+
});
|
|
624
|
+
} else {
|
|
625
|
+
changes.push({
|
|
626
|
+
action: "no-change",
|
|
627
|
+
identifier,
|
|
628
|
+
before: existing,
|
|
629
|
+
after: presetConfig,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Plan stemming dictionaries
|
|
636
|
+
if (config.stemmingDictionaries) {
|
|
637
|
+
for (const stemmingConfig of config.stemmingDictionaries) {
|
|
638
|
+
const identifier: ResourceIdentifier = {
|
|
639
|
+
type: "stemmingDictionary",
|
|
640
|
+
name: stemmingConfig.id,
|
|
641
|
+
};
|
|
642
|
+
const resourceId = formatResourceId(identifier);
|
|
643
|
+
desiredResources.add(resourceId);
|
|
644
|
+
|
|
645
|
+
const existing = await getStemmingDictionary(stemmingConfig.id);
|
|
646
|
+
|
|
647
|
+
if (!existing) {
|
|
648
|
+
changes.push({
|
|
649
|
+
action: "create",
|
|
650
|
+
identifier,
|
|
651
|
+
after: stemmingConfig,
|
|
652
|
+
diff: generateDiff(null, stemmingConfig),
|
|
653
|
+
});
|
|
654
|
+
} else if (!stemmingDictionaryConfigsEqual(existing, stemmingConfig)) {
|
|
655
|
+
changes.push({
|
|
656
|
+
action: "update",
|
|
657
|
+
identifier,
|
|
658
|
+
before: existing,
|
|
659
|
+
after: stemmingConfig,
|
|
660
|
+
diff: generateDiff(existing, stemmingConfig),
|
|
661
|
+
});
|
|
662
|
+
} else {
|
|
663
|
+
changes.push({
|
|
664
|
+
action: "no-change",
|
|
665
|
+
identifier,
|
|
666
|
+
before: existing,
|
|
667
|
+
after: stemmingConfig,
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Find resources to delete (in state but not in config)
|
|
674
|
+
for (const resource of state.resources) {
|
|
675
|
+
const resourceId = formatResourceId(resource.identifier);
|
|
676
|
+
if (!desiredResources.has(resourceId)) {
|
|
677
|
+
changes.push({
|
|
678
|
+
action: "delete",
|
|
679
|
+
identifier: resource.identifier,
|
|
680
|
+
before: resource.config,
|
|
681
|
+
diff: generateDiff(resource.config, null),
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Calculate summary
|
|
687
|
+
const summary = {
|
|
688
|
+
create: changes.filter((c) => c.action === "create").length,
|
|
689
|
+
update: changes.filter((c) => c.action === "update").length,
|
|
690
|
+
delete: changes.filter((c) => c.action === "delete").length,
|
|
691
|
+
noChange: changes.filter((c) => c.action === "no-change").length,
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
return {
|
|
695
|
+
changes,
|
|
696
|
+
hasChanges: summary.create + summary.update + summary.delete > 0,
|
|
697
|
+
summary,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Format a plan for display
|
|
703
|
+
*/
|
|
704
|
+
export function formatPlan(plan: Plan): string {
|
|
705
|
+
const lines: string[] = [];
|
|
706
|
+
|
|
707
|
+
lines.push(chalk.bold("\nTypesense Plan:\n"));
|
|
708
|
+
|
|
709
|
+
// Group changes by action
|
|
710
|
+
const creates = plan.changes.filter((c) => c.action === "create");
|
|
711
|
+
const updates = plan.changes.filter((c) => c.action === "update");
|
|
712
|
+
const deletes = plan.changes.filter((c) => c.action === "delete");
|
|
713
|
+
const noChanges = plan.changes.filter((c) => c.action === "no-change");
|
|
714
|
+
|
|
715
|
+
for (const change of creates) {
|
|
716
|
+
lines.push(
|
|
717
|
+
chalk.green(` + ${formatResourceId(change.identifier)} (create)`)
|
|
718
|
+
);
|
|
719
|
+
if (change.diff) {
|
|
720
|
+
lines.push(
|
|
721
|
+
change.diff
|
|
722
|
+
.split("\n")
|
|
723
|
+
.map((l) => ` ${l}`)
|
|
724
|
+
.join("\n")
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
lines.push("");
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
for (const change of updates) {
|
|
731
|
+
lines.push(
|
|
732
|
+
chalk.yellow(` ~ ${formatResourceId(change.identifier)} (update)`)
|
|
733
|
+
);
|
|
734
|
+
if (change.diff) {
|
|
735
|
+
lines.push(
|
|
736
|
+
change.diff
|
|
737
|
+
.split("\n")
|
|
738
|
+
.map((l) => ` ${l}`)
|
|
739
|
+
.join("\n")
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
lines.push("");
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
for (const change of deletes) {
|
|
746
|
+
lines.push(
|
|
747
|
+
chalk.red(` - ${formatResourceId(change.identifier)} (delete)`)
|
|
748
|
+
);
|
|
749
|
+
if (change.diff) {
|
|
750
|
+
lines.push(
|
|
751
|
+
change.diff
|
|
752
|
+
.split("\n")
|
|
753
|
+
.map((l) => ` ${l}`)
|
|
754
|
+
.join("\n")
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
lines.push("");
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
for (const change of noChanges) {
|
|
761
|
+
lines.push(
|
|
762
|
+
chalk.gray(` ${formatResourceId(change.identifier)} (no changes)`)
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
lines.push(chalk.bold("\nSummary:"));
|
|
767
|
+
lines.push(
|
|
768
|
+
` ${chalk.green(`${plan.summary.create} to create`)}, ` +
|
|
769
|
+
`${chalk.yellow(`${plan.summary.update} to update`)}, ` +
|
|
770
|
+
`${chalk.red(`${plan.summary.delete} to delete`)}, ` +
|
|
771
|
+
`${chalk.gray(`${plan.summary.noChange} unchanged`)}`
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
if (!plan.hasChanges) {
|
|
775
|
+
lines.push(chalk.green("\nNo changes needed. Infrastructure is up-to-date."));
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return lines.join("\n");
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Build the new state after applying a plan
|
|
783
|
+
*/
|
|
784
|
+
export function buildNewState(
|
|
785
|
+
currentState: State,
|
|
786
|
+
config: TypesenseConfig
|
|
787
|
+
): State {
|
|
788
|
+
const resources: ManagedResource[] = [];
|
|
789
|
+
const now = new Date().toISOString();
|
|
790
|
+
|
|
791
|
+
// Add collections
|
|
792
|
+
if (config.collections) {
|
|
793
|
+
for (const collectionConfig of config.collections) {
|
|
794
|
+
resources.push({
|
|
795
|
+
identifier: { type: "collection", name: collectionConfig.name },
|
|
796
|
+
config: collectionConfig,
|
|
797
|
+
checksum: computeChecksum(collectionConfig),
|
|
798
|
+
lastUpdated: now,
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Add aliases
|
|
804
|
+
if (config.aliases) {
|
|
805
|
+
for (const aliasConfig of config.aliases) {
|
|
806
|
+
resources.push({
|
|
807
|
+
identifier: { type: "alias", name: aliasConfig.name },
|
|
808
|
+
config: aliasConfig,
|
|
809
|
+
checksum: computeChecksum(aliasConfig),
|
|
810
|
+
lastUpdated: now,
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Add synonyms
|
|
816
|
+
if (config.synonyms) {
|
|
817
|
+
for (const synonymConfig of config.synonyms) {
|
|
818
|
+
resources.push({
|
|
819
|
+
identifier: {
|
|
820
|
+
type: "synonym",
|
|
821
|
+
name: synonymConfig.id,
|
|
822
|
+
collection: synonymConfig.collection,
|
|
823
|
+
},
|
|
824
|
+
config: synonymConfig,
|
|
825
|
+
checksum: computeChecksum(synonymConfig),
|
|
826
|
+
lastUpdated: now,
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Add synonym sets (Typesense 30.0+)
|
|
832
|
+
if (config.synonymSets) {
|
|
833
|
+
for (const synonymSetConfig of config.synonymSets) {
|
|
834
|
+
resources.push({
|
|
835
|
+
identifier: {
|
|
836
|
+
type: "synonymSet",
|
|
837
|
+
name: synonymSetConfig.name,
|
|
838
|
+
},
|
|
839
|
+
config: synonymSetConfig,
|
|
840
|
+
checksum: computeChecksum(synonymSetConfig),
|
|
841
|
+
lastUpdated: now,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Add overrides
|
|
847
|
+
if (config.overrides) {
|
|
848
|
+
for (const overrideConfig of config.overrides) {
|
|
849
|
+
resources.push({
|
|
850
|
+
identifier: {
|
|
851
|
+
type: "override",
|
|
852
|
+
name: overrideConfig.id,
|
|
853
|
+
collection: overrideConfig.collection,
|
|
854
|
+
},
|
|
855
|
+
config: overrideConfig,
|
|
856
|
+
checksum: computeChecksum(overrideConfig),
|
|
857
|
+
lastUpdated: now,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Add analytics rules
|
|
863
|
+
if (config.analyticsRules) {
|
|
864
|
+
for (const analyticsRuleConfig of config.analyticsRules) {
|
|
865
|
+
resources.push({
|
|
866
|
+
identifier: {
|
|
867
|
+
type: "analyticsRule",
|
|
868
|
+
name: analyticsRuleConfig.name,
|
|
869
|
+
},
|
|
870
|
+
config: analyticsRuleConfig,
|
|
871
|
+
checksum: computeChecksum(analyticsRuleConfig),
|
|
872
|
+
lastUpdated: now,
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Add API keys
|
|
878
|
+
if (config.apiKeys) {
|
|
879
|
+
for (const apiKeyConfig of config.apiKeys) {
|
|
880
|
+
resources.push({
|
|
881
|
+
identifier: {
|
|
882
|
+
type: "apiKey",
|
|
883
|
+
name: apiKeyConfig.description,
|
|
884
|
+
},
|
|
885
|
+
config: apiKeyConfig,
|
|
886
|
+
checksum: computeChecksum(apiKeyConfig),
|
|
887
|
+
lastUpdated: now,
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Add curation sets
|
|
893
|
+
if (config.curationSets) {
|
|
894
|
+
for (const curationSetConfig of config.curationSets) {
|
|
895
|
+
resources.push({
|
|
896
|
+
identifier: {
|
|
897
|
+
type: "curationSet",
|
|
898
|
+
name: curationSetConfig.name,
|
|
899
|
+
},
|
|
900
|
+
config: curationSetConfig,
|
|
901
|
+
checksum: computeChecksum(curationSetConfig),
|
|
902
|
+
lastUpdated: now,
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Add stopwords
|
|
908
|
+
if (config.stopwords) {
|
|
909
|
+
for (const stopwordConfig of config.stopwords) {
|
|
910
|
+
resources.push({
|
|
911
|
+
identifier: {
|
|
912
|
+
type: "stopword",
|
|
913
|
+
name: stopwordConfig.id,
|
|
914
|
+
},
|
|
915
|
+
config: stopwordConfig,
|
|
916
|
+
checksum: computeChecksum(stopwordConfig),
|
|
917
|
+
lastUpdated: now,
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Add presets
|
|
923
|
+
if (config.presets) {
|
|
924
|
+
for (const presetConfig of config.presets) {
|
|
925
|
+
resources.push({
|
|
926
|
+
identifier: {
|
|
927
|
+
type: "preset",
|
|
928
|
+
name: presetConfig.name,
|
|
929
|
+
},
|
|
930
|
+
config: presetConfig,
|
|
931
|
+
checksum: computeChecksum(presetConfig),
|
|
932
|
+
lastUpdated: now,
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Add stemming dictionaries
|
|
938
|
+
if (config.stemmingDictionaries) {
|
|
939
|
+
for (const stemmingConfig of config.stemmingDictionaries) {
|
|
940
|
+
resources.push({
|
|
941
|
+
identifier: {
|
|
942
|
+
type: "stemmingDictionary",
|
|
943
|
+
name: stemmingConfig.id,
|
|
944
|
+
},
|
|
945
|
+
config: stemmingConfig,
|
|
946
|
+
checksum: computeChecksum(stemmingConfig),
|
|
947
|
+
lastUpdated: now,
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
return {
|
|
953
|
+
version: currentState.version,
|
|
954
|
+
resources,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Import existing Typesense resources into state
|
|
960
|
+
*/
|
|
961
|
+
export async function importResources(): Promise<{
|
|
962
|
+
collections: CollectionConfig[];
|
|
963
|
+
aliases: AliasConfig[];
|
|
964
|
+
synonyms: SynonymConfig[];
|
|
965
|
+
synonymSets: SynonymSetConfig[];
|
|
966
|
+
overrides: OverrideConfig[];
|
|
967
|
+
curationSets: CurationSetConfig[];
|
|
968
|
+
analyticsRules: AnalyticsRuleConfig[];
|
|
969
|
+
apiKeys: ApiKeyConfig[];
|
|
970
|
+
stopwords: StopwordSetConfig[];
|
|
971
|
+
presets: PresetConfig[];
|
|
972
|
+
stemmingDictionaries: StemmingDictionaryConfig[];
|
|
973
|
+
}> {
|
|
974
|
+
const collections = await listCollections();
|
|
975
|
+
const aliases = await listAliases();
|
|
976
|
+
|
|
977
|
+
// Get synonyms and overrides from all collections
|
|
978
|
+
const collectionNames = collections.map((c) => c.name);
|
|
979
|
+
const synonyms = await listAllSynonyms(collectionNames);
|
|
980
|
+
const synonymSets = await listSynonymSets();
|
|
981
|
+
const overrides = await listAllOverrides(collectionNames);
|
|
982
|
+
const curationSets = await listCurationSets();
|
|
983
|
+
const analyticsRules = await listAnalyticsRules();
|
|
984
|
+
const stopwords = await listStopwordSets();
|
|
985
|
+
const presets = await listPresets();
|
|
986
|
+
const stemmingDictionaries = await listStemmingDictionaries();
|
|
987
|
+
|
|
988
|
+
// Get API keys (note: actual key values are not retrievable after creation)
|
|
989
|
+
// Only include non-default values to keep config minimal
|
|
990
|
+
const storedApiKeys = await listApiKeys();
|
|
991
|
+
const apiKeys: ApiKeyConfig[] = storedApiKeys.map((key) => {
|
|
992
|
+
const config: ApiKeyConfig = {
|
|
993
|
+
description: key.description,
|
|
994
|
+
actions: key.actions,
|
|
995
|
+
collections: key.collections,
|
|
996
|
+
};
|
|
997
|
+
// Only include expires_at if set
|
|
998
|
+
if (key.expires_at !== undefined) config.expires_at = key.expires_at;
|
|
999
|
+
// autodelete defaults to false, only include if true
|
|
1000
|
+
if (key.autodelete === true) config.autodelete = true;
|
|
1001
|
+
return config;
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
return { collections, aliases, synonyms, synonymSets, overrides, curationSets, analyticsRules, apiKeys, stopwords, presets, stemmingDictionaries };
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// ============================================================================
|
|
1008
|
+
// Drift Detection
|
|
1009
|
+
// ============================================================================
|
|
1010
|
+
|
|
1011
|
+
export interface DriftItem {
|
|
1012
|
+
identifier: ResourceIdentifier;
|
|
1013
|
+
type: "modified" | "deleted" | "unmanaged";
|
|
1014
|
+
stateConfig?: unknown;
|
|
1015
|
+
actualConfig?: unknown;
|
|
1016
|
+
diff?: string;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
export interface DriftReport {
|
|
1020
|
+
items: DriftItem[];
|
|
1021
|
+
hasDrift: boolean;
|
|
1022
|
+
summary: {
|
|
1023
|
+
modified: number;
|
|
1024
|
+
deleted: number;
|
|
1025
|
+
unmanaged: number;
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Normalize a config object for comparison (same as in buildPlan)
|
|
1031
|
+
*/
|
|
1032
|
+
function normalizeForComparison<T extends object>(config: T): T {
|
|
1033
|
+
const sorted = Object.keys(config)
|
|
1034
|
+
.sort()
|
|
1035
|
+
.reduce((acc, key) => {
|
|
1036
|
+
const value = config[key as keyof T];
|
|
1037
|
+
if (value !== undefined) {
|
|
1038
|
+
if (Array.isArray(value)) {
|
|
1039
|
+
acc[key as keyof T] = value.map((item) =>
|
|
1040
|
+
typeof item === "object" && item !== null
|
|
1041
|
+
? normalizeForComparison(item)
|
|
1042
|
+
: item
|
|
1043
|
+
) as T[keyof T];
|
|
1044
|
+
} else if (typeof value === "object" && value !== null) {
|
|
1045
|
+
acc[key as keyof T] = normalizeForComparison(value as object) as T[keyof T];
|
|
1046
|
+
} else {
|
|
1047
|
+
acc[key as keyof T] = value;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return acc;
|
|
1051
|
+
}, {} as T);
|
|
1052
|
+
return sorted;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Generate a diff between two configs for drift display
|
|
1057
|
+
* Only shows added and removed lines, not unchanged context
|
|
1058
|
+
*/
|
|
1059
|
+
function generateDriftDiff(stateConfig: unknown, actualConfig: unknown): string {
|
|
1060
|
+
const normalizedState = normalizeForComparison((stateConfig || {}) as object);
|
|
1061
|
+
const normalizedActual = normalizeForComparison((actualConfig || {}) as object);
|
|
1062
|
+
|
|
1063
|
+
const changes = diffJson(normalizedState, normalizedActual);
|
|
1064
|
+
|
|
1065
|
+
let result = "";
|
|
1066
|
+
for (const part of changes) {
|
|
1067
|
+
// Skip unchanged lines to keep diff concise
|
|
1068
|
+
if (!part.added && !part.removed) {
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const color = part.added ? chalk.green : chalk.red;
|
|
1073
|
+
const prefix = part.added ? "+ " : "- ";
|
|
1074
|
+
|
|
1075
|
+
const lines = part.value.split("\n").filter((line) => line.trim());
|
|
1076
|
+
for (const line of lines) {
|
|
1077
|
+
result += color(`${prefix}${line}\n`);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
return result;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Detect drift between state and actual Typesense resources
|
|
1086
|
+
* Drift occurs when resources are modified outside of tsctl
|
|
1087
|
+
*/
|
|
1088
|
+
export async function detectDrift(): Promise<DriftReport> {
|
|
1089
|
+
const state = await loadState();
|
|
1090
|
+
const items: DriftItem[] = [];
|
|
1091
|
+
|
|
1092
|
+
// Check each resource in state against actual Typesense state
|
|
1093
|
+
for (const resource of state.resources) {
|
|
1094
|
+
const { identifier, config: stateConfig } = resource;
|
|
1095
|
+
let actualConfig: unknown = null;
|
|
1096
|
+
|
|
1097
|
+
try {
|
|
1098
|
+
switch (identifier.type) {
|
|
1099
|
+
case "collection":
|
|
1100
|
+
actualConfig = await getCollection(identifier.name);
|
|
1101
|
+
break;
|
|
1102
|
+
case "alias":
|
|
1103
|
+
actualConfig = await getAlias(identifier.name);
|
|
1104
|
+
break;
|
|
1105
|
+
case "synonym":
|
|
1106
|
+
actualConfig = await getSynonym(identifier.name, identifier.collection!);
|
|
1107
|
+
break;
|
|
1108
|
+
case "override":
|
|
1109
|
+
actualConfig = await getOverride(identifier.name, identifier.collection!);
|
|
1110
|
+
break;
|
|
1111
|
+
case "apiKey":
|
|
1112
|
+
actualConfig = await getApiKey(identifier.name);
|
|
1113
|
+
break;
|
|
1114
|
+
case "curationSet":
|
|
1115
|
+
actualConfig = await getCurationSet(identifier.name);
|
|
1116
|
+
break;
|
|
1117
|
+
case "stopword":
|
|
1118
|
+
actualConfig = await getStopwordSet(identifier.name);
|
|
1119
|
+
break;
|
|
1120
|
+
case "preset":
|
|
1121
|
+
actualConfig = await getPreset(identifier.name);
|
|
1122
|
+
break;
|
|
1123
|
+
case "stemmingDictionary":
|
|
1124
|
+
actualConfig = await getStemmingDictionary(identifier.name);
|
|
1125
|
+
break;
|
|
1126
|
+
}
|
|
1127
|
+
} catch {
|
|
1128
|
+
// Resource doesn't exist or error fetching
|
|
1129
|
+
actualConfig = null;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
if (actualConfig === null) {
|
|
1133
|
+
// Resource was deleted outside of tsctl
|
|
1134
|
+
items.push({
|
|
1135
|
+
identifier,
|
|
1136
|
+
type: "deleted",
|
|
1137
|
+
stateConfig,
|
|
1138
|
+
diff: generateDriftDiff(stateConfig, null),
|
|
1139
|
+
});
|
|
1140
|
+
} else {
|
|
1141
|
+
// For collections, normalize remote: replace masked values and strip computed fields
|
|
1142
|
+
let actualForComparison = actualConfig;
|
|
1143
|
+
if (identifier.type === "collection") {
|
|
1144
|
+
actualForComparison = normalizeRemoteForComparison(
|
|
1145
|
+
actualConfig as object,
|
|
1146
|
+
stateConfig as object
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Check if resource was modified
|
|
1151
|
+
const normalizedState = normalizeForComparison(stateConfig as object);
|
|
1152
|
+
const normalizedActual = normalizeForComparison(actualForComparison as object);
|
|
1153
|
+
|
|
1154
|
+
if (JSON.stringify(normalizedState) !== JSON.stringify(normalizedActual)) {
|
|
1155
|
+
items.push({
|
|
1156
|
+
identifier,
|
|
1157
|
+
type: "modified",
|
|
1158
|
+
stateConfig,
|
|
1159
|
+
actualConfig: actualForComparison,
|
|
1160
|
+
diff: generateDriftDiff(stateConfig, actualForComparison),
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Check for unmanaged resources (exist in Typesense but not in state)
|
|
1167
|
+
const managedCollections = new Set(
|
|
1168
|
+
state.resources
|
|
1169
|
+
.filter((r) => r.identifier.type === "collection")
|
|
1170
|
+
.map((r) => r.identifier.name)
|
|
1171
|
+
);
|
|
1172
|
+
const managedAliases = new Set(
|
|
1173
|
+
state.resources
|
|
1174
|
+
.filter((r) => r.identifier.type === "alias")
|
|
1175
|
+
.map((r) => r.identifier.name)
|
|
1176
|
+
);
|
|
1177
|
+
|
|
1178
|
+
// Check for unmanaged collections
|
|
1179
|
+
const allCollections = await listCollections();
|
|
1180
|
+
for (const collection of allCollections) {
|
|
1181
|
+
if (!managedCollections.has(collection.name) && !collection.name.startsWith("_tsctl")) {
|
|
1182
|
+
items.push({
|
|
1183
|
+
identifier: { type: "collection", name: collection.name },
|
|
1184
|
+
type: "unmanaged",
|
|
1185
|
+
actualConfig: collection,
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// Check for unmanaged aliases
|
|
1191
|
+
const allAliases = await listAliases();
|
|
1192
|
+
for (const alias of allAliases) {
|
|
1193
|
+
if (!managedAliases.has(alias.name)) {
|
|
1194
|
+
items.push({
|
|
1195
|
+
identifier: { type: "alias", name: alias.name },
|
|
1196
|
+
type: "unmanaged",
|
|
1197
|
+
actualConfig: alias,
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Check for unmanaged stopwords
|
|
1203
|
+
const managedStopwords = new Set(
|
|
1204
|
+
state.resources
|
|
1205
|
+
.filter((r) => r.identifier.type === "stopword")
|
|
1206
|
+
.map((r) => r.identifier.name)
|
|
1207
|
+
);
|
|
1208
|
+
const allStopwords = await listStopwordSets();
|
|
1209
|
+
for (const sw of allStopwords) {
|
|
1210
|
+
if (!managedStopwords.has(sw.id)) {
|
|
1211
|
+
items.push({
|
|
1212
|
+
identifier: { type: "stopword", name: sw.id },
|
|
1213
|
+
type: "unmanaged",
|
|
1214
|
+
actualConfig: sw,
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// Check for unmanaged presets
|
|
1220
|
+
const managedPresets = new Set(
|
|
1221
|
+
state.resources
|
|
1222
|
+
.filter((r) => r.identifier.type === "preset")
|
|
1223
|
+
.map((r) => r.identifier.name)
|
|
1224
|
+
);
|
|
1225
|
+
const allPresets = await listPresets();
|
|
1226
|
+
for (const preset of allPresets) {
|
|
1227
|
+
if (!managedPresets.has(preset.name)) {
|
|
1228
|
+
items.push({
|
|
1229
|
+
identifier: { type: "preset", name: preset.name },
|
|
1230
|
+
type: "unmanaged",
|
|
1231
|
+
actualConfig: preset,
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Check for unmanaged curation sets (v30+)
|
|
1237
|
+
const managedCurationSets = new Set(
|
|
1238
|
+
state.resources
|
|
1239
|
+
.filter((r) => r.identifier.type === "curationSet")
|
|
1240
|
+
.map((r) => r.identifier.name)
|
|
1241
|
+
);
|
|
1242
|
+
try {
|
|
1243
|
+
const allCurationSets = await listCurationSets();
|
|
1244
|
+
for (const cs of allCurationSets) {
|
|
1245
|
+
if (!managedCurationSets.has(cs.name)) {
|
|
1246
|
+
items.push({
|
|
1247
|
+
identifier: { type: "curationSet", name: cs.name },
|
|
1248
|
+
type: "unmanaged",
|
|
1249
|
+
actualConfig: cs,
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
} catch {
|
|
1254
|
+
// Curation sets may not be available (pre-v30)
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const summary = {
|
|
1258
|
+
modified: items.filter((i) => i.type === "modified").length,
|
|
1259
|
+
deleted: items.filter((i) => i.type === "deleted").length,
|
|
1260
|
+
unmanaged: items.filter((i) => i.type === "unmanaged").length,
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
return {
|
|
1264
|
+
items,
|
|
1265
|
+
hasDrift: items.length > 0,
|
|
1266
|
+
summary,
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* Format a drift report for display
|
|
1272
|
+
*/
|
|
1273
|
+
export function formatDriftReport(report: DriftReport): string {
|
|
1274
|
+
const lines: string[] = [];
|
|
1275
|
+
|
|
1276
|
+
lines.push(chalk.bold("\nDrift Detection Report:\n"));
|
|
1277
|
+
|
|
1278
|
+
if (!report.hasDrift) {
|
|
1279
|
+
lines.push(chalk.green(" No drift detected. State matches Typesense."));
|
|
1280
|
+
return lines.join("\n");
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// Modified resources
|
|
1284
|
+
const modified = report.items.filter((i) => i.type === "modified");
|
|
1285
|
+
if (modified.length > 0) {
|
|
1286
|
+
lines.push(chalk.yellow.bold(" Modified outside of tsctl:"));
|
|
1287
|
+
for (const item of modified) {
|
|
1288
|
+
lines.push(chalk.yellow(` ~ ${formatResourceId(item.identifier)}`));
|
|
1289
|
+
if (item.diff) {
|
|
1290
|
+
lines.push(
|
|
1291
|
+
item.diff
|
|
1292
|
+
.split("\n")
|
|
1293
|
+
.map((l) => ` ${l}`)
|
|
1294
|
+
.join("\n")
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
lines.push("");
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// Deleted resources
|
|
1302
|
+
const deleted = report.items.filter((i) => i.type === "deleted");
|
|
1303
|
+
if (deleted.length > 0) {
|
|
1304
|
+
lines.push(chalk.red.bold(" Deleted outside of tsctl:"));
|
|
1305
|
+
for (const item of deleted) {
|
|
1306
|
+
lines.push(chalk.red(` - ${formatResourceId(item.identifier)}`));
|
|
1307
|
+
}
|
|
1308
|
+
lines.push("");
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Unmanaged resources
|
|
1312
|
+
const unmanaged = report.items.filter((i) => i.type === "unmanaged");
|
|
1313
|
+
if (unmanaged.length > 0) {
|
|
1314
|
+
lines.push(chalk.cyan.bold(" Unmanaged resources (not in config):"));
|
|
1315
|
+
for (const item of unmanaged) {
|
|
1316
|
+
lines.push(chalk.cyan(` ? ${formatResourceId(item.identifier)}`));
|
|
1317
|
+
}
|
|
1318
|
+
lines.push("");
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// Summary
|
|
1322
|
+
lines.push(chalk.bold("Summary:"));
|
|
1323
|
+
lines.push(
|
|
1324
|
+
` ${chalk.yellow(`${report.summary.modified} modified`)}, ` +
|
|
1325
|
+
`${chalk.red(`${report.summary.deleted} deleted`)}, ` +
|
|
1326
|
+
`${chalk.cyan(`${report.summary.unmanaged} unmanaged`)}`
|
|
1327
|
+
);
|
|
1328
|
+
|
|
1329
|
+
lines.push(chalk.gray("\n Run 'tsctl apply' to reconcile state with config."));
|
|
1330
|
+
lines.push(chalk.gray(" Run 'tsctl import' to add unmanaged resources to config."));
|
|
1331
|
+
|
|
1332
|
+
return lines.join("\n");
|
|
1333
|
+
}
|