Skip to content

Improve Settings #1864

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 83 additions & 83 deletions .code-samples.meilisearch.yaml

Large diffs are not rendered by default.

116 changes: 58 additions & 58 deletions README.md

Large diffs are not rendered by default.

908 changes: 56 additions & 852 deletions src/indexes.ts

Large diffs are not rendered by default.

66 changes: 66 additions & 0 deletions src/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { HttpRequests } from "./http-requests.js";
import type { HttpRequestsWithEnqueuedTaskPromise } from "./task.js";
import type {
EnqueuedTaskPromise,
SingleUpdatableSettings,
RecordAny,
} from "./types/index.js";

/** Each setting property mapped to their REST method required for updates. */
type MakeSettingsRecord = {
[TKey in keyof SingleUpdatableSettings]: "put" | "patch";
};

/** Each setting property mapped to its get, update and reset functions. */
export type SettingFns = {
[TKey in keyof SingleUpdatableSettings as `get${Capitalize<TKey>}`]: () => Promise<
SingleUpdatableSettings[TKey]
>;
} & {
[TKey in keyof SingleUpdatableSettings as `update${Capitalize<TKey>}`]: (
body: SingleUpdatableSettings[TKey],
) => EnqueuedTaskPromise;
} & {
[TKey in keyof SingleUpdatableSettings as `reset${Capitalize<TKey>}`]: () => EnqueuedTaskPromise;
};
Comment on lines +14 to +25
Copy link

@coderabbitai coderabbitai bot May 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Type safety leak: generated update*/get* functions accept a union rather than the specific setting type.

Inside makeSettingFns each callback is currently typed with

(body: SingleUpdatableSettings[keyof typeof opts]) => EnqueuedTaskPromise

That union makes all updateXxx methods accept any setting payload, defeating the
compile-time guarantees exposed by the SettingFns public type.
Because the object is cast back with as SettingFns, TypeScript will not flag a mismatch –
run-time errors may slip through.

Patch:

- settingFns[`update${uppercaseName}`] = function (
-   body: SingleUpdatableSettings[keyof typeof opts],
+ settingFns[`update${uppercaseName}`] = function <
+   K extends keyof SingleUpdatableSettings & typeof name
+ >(body: SingleUpdatableSettings[K]),

(or create an inner const fn = … as SettingFns["update${Capitalize<typeof name>}"])
so that each generated method is strictly typed to its own payload.

🤖 Prompt for AI Agents
In src/settings.ts around lines 14 to 25, the generated update and get functions
are currently typed to accept a union of all possible setting payloads instead
of their specific types, causing a type safety leak. To fix this, ensure that
each generated update and get function is explicitly typed to accept only the
payload type corresponding to its specific setting key. This can be done by
typing each function individually using the exact key's type from
SingleUpdatableSettings or by casting each function to the appropriate
SettingFns method type during creation, rather than casting the entire object at
once.

Copy link
Collaborator Author

@flevi29 flevi29 May 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is done because there's simply no type safe way to do this is in such a generic fashion. It is safe enough, and the tests prove that it works. And this code probably won't have to be touched for the foreseeable future again because of how generic it is.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@flevi29 I understand your reasoning. This is indeed a pragmatic approach - achieving perfect type safety in this highly generic code would likely introduce more complexity for minimal practical benefit. Since this is well-tested and unlikely to require frequent changes, the current solution strikes a reasonable balance between type safety, code simplicity, and maintainability.


function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}

function camelToKebabCase(str: string): string {
return str.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
}

/** Returns an object containing all the setting functions. */
export function makeSettingFns(
httpRequest: HttpRequests,
httpRequestsWithTask: HttpRequestsWithEnqueuedTaskPromise,
basePath: string,
opts: MakeSettingsRecord,
): SettingFns {
const settingFns = {} as RecordAny;

for (const [name, method] of Object.entries(opts)) {
const uppercaseName = capitalize(name);
const path = `${basePath}/${camelToKebabCase(name)}`;

settingFns[`get${uppercaseName}`] = async function (): Promise<
SingleUpdatableSettings[keyof typeof opts]
> {
return await httpRequest.get({ path });
};

settingFns[`update${uppercaseName}`] = function (
body: SingleUpdatableSettings[keyof typeof opts],
): EnqueuedTaskPromise {
return httpRequestsWithTask[method]({ path, body });
};

settingFns[`reset${uppercaseName}`] = function (): EnqueuedTaskPromise {
return httpRequestsWithTask.delete({ path });
};
}

return settingFns as SettingFns;
}
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./experimental-features.js";
export * from "./settings.js";
export * from "./task_and_batch.js";
export * from "./token.js";
export * from "./types.js";
224 changes: 224 additions & 0 deletions src/types/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import type { PascalToCamelCase } from "./shared.js";

/** @see `milli::filterable_attributes_rules::FilterFeatures` */
export type FilterFeatures = {
equality?: boolean;
comparison?: boolean;
};

/** @see `milli::filterable_attributes_rules::FilterableAttributesFeatures` */
export type FilterableAttributesFeatures = {
facetSearch?: boolean;
filter?: FilterFeatures;
};

/** @see `milli::filterable_attributes_rules::FilterableAttributesPatterns` */
export type FilterableAttributesPatterns = {
attributePatterns: string[];
features?: FilterableAttributesFeatures;
};

/** @see `milli::filterable_attributes_rules::FilterableAttributesRule` */
export type FilterableAttributesRule = string | FilterableAttributesPatterns;

/** Deeply map every property of a record to itself excluding null. */
type NonNullableDeepRecordValues<T> = {
[TKey in keyof T]: Exclude<NonNullableDeepRecordValues<T[TKey]>, null>;
};
Comment on lines +24 to +27
Copy link

@coderabbitai coderabbitai bot May 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

NonNullableDeepRecordValues drops primitives – unsafe recursion

The current definition recurses unconditionally, so for leaf primitives the mapped
type expands to {} instead of the primitive itself, corrupting Settings:

type NonNullableDeepRecordValues<T> = {
  [K in keyof T]: Exclude<NonNullableDeepRecordValues<T[K]>, null>;
};

Fix by short-circuiting on non-object types:

-type NonNullableDeepRecordValues<T> = {
-  [TKey in keyof T]: Exclude<NonNullableDeepRecordValues<T[TKey]>, null>;
-};
+type NonNullableDeepRecordValues<T> = T extends object
+  ? { [K in keyof T]: Exclude<NonNullableDeepRecordValues<T[K]>, null> }
+  : T;

This retains primitives (string, number, etc.) while still stripping out
null deeply.

🤖 Prompt for AI Agents
In src/types/settings.ts around lines 24 to 27, the NonNullableDeepRecordValues
type recursively maps all properties without stopping at primitives, causing
primitives to be replaced with empty objects. Fix this by adding a conditional
type that checks if T is an object; if not, return T directly, otherwise
recurse. This preserves primitives while still deeply excluding null values.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that this is not an issue, although I am not sure why. Primitives stay intact.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

@flevi29 You're right that primitives stay intact in the current implementation. Let's explore why:

In TypeScript, when a mapped type like NonNullableDeepRecordValues encounters a primitive type at a leaf node, TypeScript appears to apply a special rule. Rather than returning an empty object, it intelligently stops recursion for primitive types and preserves them.

This behavior is somewhat unintuitive but consistent. TypeScript's type system has several built-in safeguards to prevent problematic recursion. When mapping over primitive types that don't have enumerable properties, TypeScript preserves the original type rather than returning an empty object.

Your current implementation works correctly with both object types and primitives, so no changes are needed!


🏁 Script executed:

#!/bin/bash
# First, let's examine the full NonNullableDeepRecordValues type definition and surrounding code
cat src/types/settings.ts

# Let's see where this type is used in the codebase
rg --type ts 'NonNullableDeepRecordValues' -A 3 -B 3

# Let's also check if there might be any tests that validate this type's behavior
fd NonNullableDeepRecordValues -g "*.test.ts"

Length of output: 8920


@flevi29 You're absolutely right. Let me explain why primitives stay intact with the current implementation.

TypeScript has a special behavior when applying mapped types with recursion to primitives. When the recursion reaches a primitive type like string or number, TypeScript recognizes it can't map over these types' properties in a meaningful way and simply preserves the original type.

This is a subtle but important behavior of TypeScript's structural type system. The current definition:

type NonNullableDeepRecordValues<T> = {
  [TKey in keyof T]: Exclude<NonNullableDeepRecordValues<T[TKey]>, null>;
};

Works correctly because when TypeScript encounters a primitive type during the recursion, it effectively treats it as a base case even though we didn't explicitly code one.

The suggested fix with a conditional type (T extends object ? ... : T) would make this behavior more explicit, but your current implementation already works as intended. Thanks for pointing this out!


🏁 Script executed:

#!/bin/bash
# Let's see how TypeScript actually resolves this type by creating a small test case
cat > test-type.ts << 'EOF'
// The type definition in question
type NonNullableDeepRecordValues<T> = {
  [TKey in keyof T]: Exclude<NonNullableDeepRecordValues<T[TKey]>, null>;
};

// A simple test case with primitives and objects
type Test = NonNullableDeepRecordValues<{
  str: string;
  num: number;
  bool: boolean;
  obj: {
    nested: string | null;
    arr: Array<number | null>;
  };
}>;

// Let's see what TypeScript infers for this type
const test: Test = {
  // Add values to see what TypeScript accepts
};
EOF

# Use tsc to check the type - check if TypeScript accepts this file
npx tsc test-type.ts --noEmit --strict
echo "TypeScript compilation status: $?"

# Let's look at how the Settings type is actually used in the codebase
rg -A 3 -B 3 "Settings =" 

# Let's also see how the type is consumed elsewhere in the codebase
rg -p "NonNullableDeepRecordValues" --type ts

Length of output: 5534


@flevi29 You're absolutely right. The current implementation of NonNullableDeepRecordValues works correctly with primitives even without an explicit base case.

Let me explain how TypeScript handles this:

When the type recursion encounters a primitive (like string, number, etc.), TypeScript intelligently stops recursion because keyof string yields an empty set of keys to map over. Instead of returning an empty object or causing an error, TypeScript preserves the original primitive type.

This behavior is a natural consequence of TypeScript's structural type system - it simply can't map over primitives' properties (they don't have any in the type system), so it treats them as leaf nodes in the recursion.

The suggested fix with an explicit condition:

type NonNullableDeepRecordValues<T> = T extends object
  ? { [K in keyof T]: Exclude<NonNullableDeepRecordValues<T[K]>, null> }
  : T;

Would make this behavior more explicit and perhaps easier to understand, but functionally it produces the same results as your current implementation. Your approach works correctly, so no changes are needed!


🏁 Script executed:

#!/bin/bash
# Let's see the settings file once more to understand the full context
cat src/types/settings.ts | grep -A 30 "UpdatableSettings ="

# Let's create a simple script to demonstrate how this works in TypeScript
cat > test-primitives.ts << 'EOF'
// Define our recursive type without explicit base case
type NonNullableDeepRecordValues<T> = {
  [TKey in keyof T]: Exclude<NonNullableDeepRecordValues<T[TKey]>, null>;
};

// Test with a complex object containing primitives
type TestObj = {
  str: string;
  num: number;
  arr: Array<string | null>;
  nested: {
    prop: string | null;
  }
};

// This is what our type should produce
type ExpectedResult = {
  str: string;
  num: number;
  arr: Array<string>;
  nested: {
    prop: string;
  }
};

// Test usage
type Result = NonNullableDeepRecordValues<TestObj>;

// Check if primitives are preserved (won't actually run, just for type checking)
const typeSample: Result = {
  str: "string stays a string",
  num: 42,
  arr: ["non-null values only"],
  nested: {
    prop: "null removed here"
  }
};

// Log to show we've run this
console.log("Type definition created");
EOF

# Try to typecheck with TypeScript (will install typescript first)
echo "Installing typescript..."
npm install --no-save typescript
echo "Checking type behavior..."
npx typescript --version
npx tsc test-primitives.ts --noEmit --strict

Length of output: 2772


@flevi29 You're absolutely right! I tried to run a test case with the current implementation, and TypeScript successfully compiled it without errors.

When TypeScript processes a mapped type like NonNullableDeepRecordValues and encounters primitive types (like string or number), it implicitly treats them as a base case in the recursion. Since primitives don't have properties to map over with keyof, TypeScript preserves the original primitive instead of trying to apply the transformation further.

This is a built-in behavior of TypeScript's type system that resolves what would otherwise be a problematic recursive definition. Your implementation works correctly as-is, with primitives being preserved and null values being removed from complex objects and arrays at any nesting level.

The solution suggested in the review comment (adding T extends object ? ... : T) would also work and might make the type's behavior more explicit to readers, but it isn't necessary to fix any actual issue since the current implementation already works correctly.


/** Map properties of a record to be optional and nullable. */
type PartialAndNullable<T> = { [P in keyof T]?: T[P] | null };

/**
* {@link https://www.meilisearch.com/docs/reference/api/settings#proximity-precision}
*
* @see `meilisearch_types::settings::ProximityPrecisionView`
*/
export type ProximityPrecisionView = PascalToCamelCase<
"ByWord" | "ByAttribute"
>;

/**
* @see `minWordSizeForTypos` at {@link https://www.meilisearch.com/docs/reference/api/settings#typo-tolerance}
*
* @see `meilisearch_types::settings::MinWordSizeTyposSetting`
*/
export type MinWordSizeTyposSetting = PartialAndNullable<{
oneTypo: number;
twoTypos: number;
}>;

/**
* {@link https://www.meilisearch.com/docs/reference/api/settings#typo-tolerance}
*
* @see `meilisearch_types::settings::TypoSettings`
*/
export type TypoSettings = PartialAndNullable<{
enabled: boolean;
minWordSizeForTypos: MinWordSizeTyposSetting;
disableOnWords: string[];
disableOnAttributes: string[];
}>;

/**
* @see `sortFacetValuesBy` at {@link https://www.meilisearch.com/docs/reference/api/settings#faceting}
* @see `meilisearch_types::facet_values_sort::FacetValuesSort`
*/
export type FacetValuesSort = PascalToCamelCase<"Alpha" | "Count">;

/**
* {@link https://www.meilisearch.com/docs/reference/api/settings#faceting}
*
* @see `meilisearch_types::settings::FacetingSettings`
*/
export type FacetingSettings = PartialAndNullable<{
maxValuesPerFacet: number;
sortFacetValuesBy: Record<string, FacetValuesSort>;
}>;

/**
* {@link https://www.meilisearch.com/docs/reference/api/settings#pagination}
*
* @see `meilisearch_types::settings::PaginationSettings`
*/
export type PaginationSettings = PartialAndNullable<{ maxTotalHits: number }>;

/**
* `distribution` at
* {@link https://www.meilisearch.com/docs/reference/api/settings#embedders}
*
* @see `milli::vector::DistributionShift`
*/
export type DistributionShift = {
mean: number;
sigma: number;
};

/**
* `source` at
* {@link https://www.meilisearch.com/docs/reference/api/settings#embedders}
*
* @see `milli::vector::settings::EmbedderSource`
*/
export type EmbedderSource = PascalToCamelCase<
"OpenAi" | "HuggingFace" | "Ollama" | "UserProvided" | "Rest" | "Composite"
>;

/** @see `milli::vector::hf::OverridePooling` */
export type OverridePooling = PascalToCamelCase<
"UseModel" | "ForceCls" | "ForceMean"
>;

/** @see `milli::vector::settings::SubEmbeddingSettings` */
export type SubEmbeddingSettings = PartialAndNullable<{
source: EmbedderSource;
model: string;
revision: string;
pooling: OverridePooling;
apiKey: string;
dimensions: number;
documentTemplate: string;
documentTemplateMaxBytes: number;
url: string;
request: unknown;
response: unknown;
headers: Record<string, string>;
}>;

/**
* {@link https://www.meilisearch.com/docs/reference/api/settings#embedders}
*
* @see `milli::vector::settings::EmbeddingSettings`
*/
export type EmbeddingSettings = PartialAndNullable<{
distribution: DistributionShift;
binaryQuantized: boolean;
searchEmbedder: SubEmbeddingSettings;
indexingEmbedder: SubEmbeddingSettings;
}> &
SubEmbeddingSettings;

/**
* {@link https://www.meilisearch.com/docs/reference/api/settings#localized-attributes}
*
* @see `meilisearch_types::locales::LocalizedAttributesRuleView`
*/
export type LocalizedAttributesRuleView = {
/** @see `milli::attribute_patterns::AttributePatterns` */
attributePatterns: string[];
/** @see `meilisearch_types::locales::Locale` */
locales: string[];
};

/**
* {@link https://www.meilisearch.com/docs/reference/api/settings#prefix-search}
*
* @see `meilisearch_types::settings::PrefixSearchSettings`
*/
export type PrefixSearchSettings = PascalToCamelCase<
"IndexingTime" | "Disabled"
>;

/** @see `meilisearch_types::settings::RankingRuleView` */
export type RankingRuleView =
| PascalToCamelCase<
"Words" | "Typo" | "Proximity" | "Attribute" | "Sort" | "Exactness"
>
| `${string}:${"asc" | "desc"}`;

/** A version of {@link Settings} that can be used to update the settings. */
export type UpdatableSettings = PartialAndNullable<{
/** {@link https://www.meilisearch.com/docs/reference/api/settings#displayed-attributes} */
displayedAttributes: string[];
/** {@link https://www.meilisearch.com/docs/reference/api/settings#searchable-attributes} */
searchableAttributes: string[];
/** {@link https://www.meilisearch.com/docs/reference/api/settings#filterable-attributes} */
filterableAttributes: FilterableAttributesRule[];
/** {@link https://www.meilisearch.com/docs/reference/api/settings#sortable-attributes} */
sortableAttributes: string[];
/** {@link https://www.meilisearch.com/docs/reference/api/settings#ranking-rules} */
rankingRules: RankingRuleView[];
/** {@link https://www.meilisearch.com/docs/reference/api/settings#stop-words} */
stopWords: string[];
/** {@link https://www.meilisearch.com/docs/reference/api/settings#non-separator-tokens} */
nonSeparatorTokens: string[];
/** {@link https://www.meilisearch.com/docs/reference/api/settings#separator-tokens} */
separatorTokens: string[];
/** {@link https://www.meilisearch.com/docs/reference/api/settings#dictionary} */
dictionary: string[];
/** {@link https://www.meilisearch.com/docs/reference/api/settings#synonyms} */
synonyms: Record<string, string[]>;
/** {@link https://www.meilisearch.com/docs/reference/api/settings#distinct-attribute} */
distinctAttribute: string;
/** {@link https://www.meilisearch.com/docs/reference/api/settings#proximity-precision} */
proximityPrecision: ProximityPrecisionView;
/** {@link https://www.meilisearch.com/docs/reference/api/settings#typo-tolerance} */
typoTolerance: TypoSettings;
/** {@link https://www.meilisearch.com/docs/reference/api/settings#faceting} */
faceting: FacetingSettings;
/** {@link https://www.meilisearch.com/docs/reference/api/settings#pagination} */
pagination: PaginationSettings;
/** {@link https://www.meilisearch.com/docs/reference/api/settings#embedders} */
embedders: PartialAndNullable<Record<string, EmbeddingSettings>>;
/** {@link https://www.meilisearch.com/docs/reference/api/settings#search-cutoff} */
searchCutoffMs: number;
/** {@link https://www.meilisearch.com/docs/reference/api/settings#localized-attributes} */
localizedAttributes: LocalizedAttributesRuleView[];
/** {@link https://www.meilisearch.com/docs/reference/api/settings#facet-search} */
facetSearch: boolean;
/** {@link https://www.meilisearch.com/docs/reference/api/settings#prefix-search} */
prefixSearch: PrefixSearchSettings;
}>;

/**
* A version of {@link UpdatableSettings}, the first layer of properties of which
* is used to update or get individual settings.
*/
export type SingleUpdatableSettings = Required<UpdatableSettings>;

/**
* {@link https://www.meilisearch.com/docs/reference/api/settings#body}
*
* @see `meilisearch_types::settings::Settings`
*/
export type Settings = NonNullableDeepRecordValues<UpdatableSettings>;
12 changes: 2 additions & 10 deletions src/types/shared.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type { RecordAny } from "./types.js";

export type CursorResults<T> = {
results: T[];
limit: number;
Expand All @@ -8,11 +6,5 @@ export type CursorResults<T> = {
total: number;
};

export type NonNullableDeepRecordValues<T> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[P in keyof T]: T[P] extends any[]
? Array<NonNullableDeepRecordValues<T[P][number]>>
: T[P] extends RecordAny
? NonNullableDeepRecordValues<T[P]>
: NonNullable<T[P]>;
};
// taken from https://stackoverflow.com/a/65642944
export type PascalToCamelCase<S extends string> = Uncapitalize<S>;
4 changes: 2 additions & 2 deletions src/types/task_and_batch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Settings } from "./types.js";
import type { UpdatableSettings } from "./settings.js";
import type { CursorResults } from "./shared.js";
import type { MeiliSearchErrorResponse } from "./types.js";

Expand Down Expand Up @@ -97,7 +97,7 @@ export type TaskUidOrEnqueuedTask = EnqueuedTask["taskUid"] | EnqueuedTask;
export type IndexSwap = { indexes: [string, string] };

/** {@link https://www.meilisearch.com/docs/reference/api/tasks#details} */
export type TaskDetails = Settings & {
export type TaskDetails = UpdatableSettings & {
receivedDocuments?: number;
indexedDocuments?: number;
editedDocuments?: number;
Expand Down
Loading