@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,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
+ }