@equinor/fusion-framework-cli-plugin-ai-index 2.0.1 → 3.0.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 (78) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/esm/bin/apply-metadata.js +15 -5
  3. package/dist/esm/bin/apply-metadata.js.map +1 -1
  4. package/dist/esm/bin/apply-schema.js +64 -0
  5. package/dist/esm/bin/apply-schema.js.map +1 -0
  6. package/dist/esm/bin/apply-schema.test.js +143 -0
  7. package/dist/esm/bin/apply-schema.test.js.map +1 -0
  8. package/dist/esm/bin/delete-removed-files.js +1 -1
  9. package/dist/esm/bin/delete-removed-files.js.map +1 -1
  10. package/dist/esm/bin/embed.js +265 -55
  11. package/dist/esm/bin/embed.js.map +1 -1
  12. package/dist/esm/bin/get-diff.js +5 -0
  13. package/dist/esm/bin/get-diff.js.map +1 -1
  14. package/dist/esm/create-command.js +186 -0
  15. package/dist/esm/create-command.js.map +1 -0
  16. package/dist/esm/delete-command.js +14 -2
  17. package/dist/esm/delete-command.js.map +1 -1
  18. package/dist/esm/delete-command.options.js +7 -31
  19. package/dist/esm/delete-command.options.js.map +1 -1
  20. package/dist/esm/delete-index-command.js +94 -0
  21. package/dist/esm/delete-index-command.js.map +1 -0
  22. package/dist/esm/embed-command.js +30 -0
  23. package/dist/esm/embed-command.js.map +1 -0
  24. package/dist/esm/embeddings-command.js +14 -17
  25. package/dist/esm/embeddings-command.js.map +1 -1
  26. package/dist/esm/embeddings-command.options.js +12 -43
  27. package/dist/esm/embeddings-command.options.js.map +1 -1
  28. package/dist/esm/index.js +12 -3
  29. package/dist/esm/index.js.map +1 -1
  30. package/dist/esm/schema.js +41 -0
  31. package/dist/esm/schema.js.map +1 -0
  32. package/dist/esm/search-command.js +17 -5
  33. package/dist/esm/search-command.js.map +1 -1
  34. package/dist/esm/utils/embedding-dimensions.js +37 -0
  35. package/dist/esm/utils/embedding-dimensions.js.map +1 -0
  36. package/dist/esm/utils/zod-to-azure-fields.js +120 -0
  37. package/dist/esm/utils/zod-to-azure-fields.js.map +1 -0
  38. package/dist/esm/utils/zod-to-azure-fields.test.js +112 -0
  39. package/dist/esm/utils/zod-to-azure-fields.test.js.map +1 -0
  40. package/dist/esm/version.js +1 -1
  41. package/dist/tsconfig.tsbuildinfo +1 -1
  42. package/dist/types/bin/apply-metadata.d.ts +2 -1
  43. package/dist/types/bin/apply-schema.d.ts +22 -0
  44. package/dist/types/bin/apply-schema.test.d.ts +1 -0
  45. package/dist/types/config.d.ts +14 -0
  46. package/dist/types/create-command.d.ts +6 -0
  47. package/dist/types/delete-command.options.d.ts +10 -23
  48. package/dist/types/delete-index-command.d.ts +6 -0
  49. package/dist/types/embed-command.d.ts +12 -0
  50. package/dist/types/embeddings-command.options.d.ts +10 -28
  51. package/dist/types/index.d.ts +1 -0
  52. package/dist/types/schema.d.ts +137 -0
  53. package/dist/types/utils/embedding-dimensions.d.ts +13 -0
  54. package/dist/types/utils/zod-to-azure-fields.d.ts +61 -0
  55. package/dist/types/utils/zod-to-azure-fields.test.d.ts +1 -0
  56. package/dist/types/version.d.ts +1 -1
  57. package/package.json +5 -5
  58. package/src/bin/apply-metadata.ts +20 -4
  59. package/src/bin/apply-schema.test.ts +170 -0
  60. package/src/bin/apply-schema.ts +86 -0
  61. package/src/bin/delete-removed-files.ts +1 -1
  62. package/src/bin/embed.ts +325 -77
  63. package/src/bin/get-diff.ts +5 -0
  64. package/src/config.ts +15 -0
  65. package/src/create-command.ts +218 -0
  66. package/src/delete-command.options.ts +7 -37
  67. package/src/delete-command.ts +19 -2
  68. package/src/delete-index-command.ts +121 -0
  69. package/src/embed-command.ts +44 -0
  70. package/src/embeddings-command.options.ts +12 -50
  71. package/src/embeddings-command.ts +18 -18
  72. package/src/index.ts +12 -3
  73. package/src/schema.ts +149 -0
  74. package/src/search-command.ts +22 -5
  75. package/src/utils/embedding-dimensions.ts +39 -0
  76. package/src/utils/zod-to-azure-fields.test.ts +136 -0
  77. package/src/utils/zod-to-azure-fields.ts +177 -0
  78. package/src/version.ts +1 -1
package/src/schema.ts ADDED
@@ -0,0 +1,149 @@
1
+ import type { z } from 'zod';
2
+ import type { VectorStoreDocument } from '@equinor/fusion-framework-module-ai/lib';
3
+
4
+ /**
5
+ * Attribute map type used by {@link IndexSchemaConfig.prepareAttributes}.
6
+ *
7
+ * Combines the schema-declared field types (all optional, since
8
+ * attributes are built up incrementally) with a `Record<string, unknown>`
9
+ * base so non-promoted attributes are still accessible.
10
+ *
11
+ * @template T - Zod object schema from which attribute types are derived.
12
+ */
13
+ export type SchemaAttributes<T extends z.ZodObject> = Partial<z.input<T>> & Record<string, unknown>;
14
+
15
+ /**
16
+ * Configuration for a custom Azure AI Search index schema defined via a Zod
17
+ * object shape.
18
+ *
19
+ * Declares which metadata fields should be promoted to top-level Azure AI
20
+ * Search fields (instead of being stored in the generic `attributes` array)
21
+ * and how their values are resolved from each document.
22
+ *
23
+ * Promoted fields become filterable/facetable at the Azure Search level,
24
+ * eliminating the need for `any()` OData operators.
25
+ *
26
+ * @template T - Zod object schema type that defines the promoted field names and types.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * import { z } from 'zod';
31
+ * import { defineIndexSchema } from '@equinor/fusion-framework-cli-plugin-ai-index';
32
+ *
33
+ * const schema = defineIndexSchema({
34
+ * shape: z.object({
35
+ * pkg_name: z.string().optional(),
36
+ * type: z.string(),
37
+ * tags: z.array(z.string()).default([]),
38
+ * source_dir: z.string(),
39
+ * }),
40
+ * prepareAttributes: (attrs, doc) => {
41
+ * // attrs.tags is typed as string[] | undefined ✅
42
+ * attrs.tags ??= [];
43
+ * if (doc.metadata.source.includes('packages/')) {
44
+ * attrs.tags.push('package');
45
+ * }
46
+ * return attrs;
47
+ * },
48
+ * resolve: (doc) => ({
49
+ * pkg_name: doc.metadata.attributes?.pkg_name as string | undefined,
50
+ * type: (doc.metadata.attributes?.type as string) ?? 'unknown',
51
+ * tags: (doc.metadata.attributes?.tags as string[]) ?? [],
52
+ * source_dir: doc.metadata.source.split('/')[0],
53
+ * }),
54
+ * });
55
+ * ```
56
+ */
57
+ export interface IndexSchemaConfig<T extends z.ZodObject = z.ZodObject> {
58
+ /**
59
+ * Zod object schema defining the promoted field names and their types.
60
+ *
61
+ * Each key becomes a top-level Azure AI Search field. The Zod type
62
+ * determines the Azure EDM field type:
63
+ * - `z.string()` → `Edm.String` (filterable, facetable)
64
+ * - `z.array(z.string())` → `Collection(Edm.String)` (filterable, facetable)
65
+ * - `z.number()` → `Edm.Double` (filterable, sortable)
66
+ * - `z.boolean()` → `Edm.Boolean` (filterable)
67
+ */
68
+ shape: T;
69
+
70
+ /**
71
+ * Type-safe attribute processor that enriches document attributes before
72
+ * the schema resolver runs.
73
+ *
74
+ * Runs in addition to the untyped `metadata.attributeProcessor` callback
75
+ * when a schema is defined. The `attributes` parameter is typed from the
76
+ * Zod shape so that schema-declared fields (e.g. `tags`, `pkg_name`)
77
+ * have proper types while non-schema attributes remain accessible via
78
+ * the `Record<string, unknown>` base.
79
+ *
80
+ * Runs after git and package metadata enrichment and after
81
+ * `metadata.attributeProcessor`, before
82
+ * {@link IndexSchemaConfig.resolve | resolve}.
83
+ *
84
+ * @param attributes - The accumulated attributes for the document, typed
85
+ * from the schema shape. All schema fields are optional since they may
86
+ * not be populated yet.
87
+ * @param document - The vector-store document being processed.
88
+ * @returns The enriched attributes map.
89
+ */
90
+ prepareAttributes?: (
91
+ attributes: SchemaAttributes<T>,
92
+ document: VectorStoreDocument,
93
+ ) => SchemaAttributes<T>;
94
+
95
+ /**
96
+ * Per-document resolver that extracts or computes promoted field values.
97
+ *
98
+ * Runs after {@link IndexSchemaConfig.prepareAttributes | prepareAttributes}
99
+ * and metadata enrichment (git, package), so all enriched attributes are
100
+ * available on the document.
101
+ *
102
+ * @param document - The fully enriched vector-store document.
103
+ * @returns An object matching the Zod shape with resolved field values.
104
+ */
105
+ resolve: (document: VectorStoreDocument) => z.output<T>;
106
+ }
107
+
108
+ /**
109
+ * Type-safe factory for creating an {@link IndexSchemaConfig}.
110
+ *
111
+ * Infers `T` from the Zod shape and constrains both the
112
+ * `prepareAttributes` parameter types and the `resolve` return type,
113
+ * providing compile-time safety that attribute processing and resolution
114
+ * match the declared schema.
115
+ *
116
+ * @template T - Zod object schema type, inferred from `config.shape`.
117
+ * @param config - Schema configuration with a Zod shape, optional typed
118
+ * attribute processor, and a resolver function.
119
+ * @returns The same config object, narrowed to the inferred generic type.
120
+ *
121
+ * @example
122
+ * ```ts
123
+ * import { z } from 'zod';
124
+ * import { defineIndexSchema } from '@equinor/fusion-framework-cli-plugin-ai-index';
125
+ *
126
+ * const schema = defineIndexSchema({
127
+ * shape: z.object({
128
+ * tags: z.array(z.string()).default([]),
129
+ * type: z.string(),
130
+ * }),
131
+ * prepareAttributes: (attrs, doc) => {
132
+ * attrs.tags ??= []; // string[] | undefined — type-safe ✅
133
+ * if (doc.metadata.source.includes('cookbooks/')) {
134
+ * attrs.tags.push('cookbook');
135
+ * }
136
+ * return attrs;
137
+ * },
138
+ * resolve: (doc) => ({
139
+ * tags: (doc.metadata.attributes?.tags as string[]) ?? [],
140
+ * type: (doc.metadata.attributes?.type as string) ?? 'raw',
141
+ * }),
142
+ * });
143
+ * ```
144
+ */
145
+ export function defineIndexSchema<T extends z.ZodObject>(
146
+ config: IndexSchemaConfig<T>,
147
+ ): IndexSchemaConfig<T> {
148
+ return config;
149
+ }
@@ -2,11 +2,12 @@ import { createCommand, createOption } from 'commander';
2
2
  import type { Document } from '@langchain/core/documents';
3
3
  import { inspect } from 'node:util';
4
4
 
5
- import { setupFramework } from '@equinor/fusion-framework-cli-plugin-ai-base';
5
+ import { loadFusionAIConfig, setupFramework } from '@equinor/fusion-framework-cli-plugin-ai-base';
6
6
  import {
7
7
  withOptions as withAiOptions,
8
8
  type AiOptions,
9
9
  } from '@equinor/fusion-framework-cli-plugin-ai-base/command-options';
10
+ import type { FusionAIConfigWithIndex } from './config.js';
10
11
  import type { RetrieverOptions } from '@equinor/fusion-framework-module-ai/lib';
11
12
 
12
13
  /**
@@ -107,6 +108,7 @@ const normalizeMetadata = (metadata: Record<string, unknown>): Record<string, un
107
108
  */
108
109
  const _command = createCommand('search')
109
110
  .description('Search the vector store to validate embeddings and retrieve relevant documents')
111
+ .addOption(createOption('--config <config>', 'Path to a config file').default('fusion-ai.config'))
110
112
  .addOption(
111
113
  createOption('--limit <number>', 'Maximum number of results to return')
112
114
  .default(10)
@@ -124,6 +126,21 @@ const _command = createCommand('search')
124
126
  .addOption(createOption('--raw', 'Output raw metadata without normalization').default(false))
125
127
  .addOption(createOption('--verbose', 'Enable verbose output').default(false))
126
128
  .argument('<query>', 'Search query string')
129
+ .hook('preAction', async (thisCommand) => {
130
+ const opts = thisCommand.opts();
131
+ const config = await loadFusionAIConfig<FusionAIConfigWithIndex>(
132
+ (opts.config as string) ?? 'fusion-ai.config',
133
+ { baseDir: process.cwd() },
134
+ );
135
+ const indexConfig = config.index ?? {};
136
+
137
+ if (indexConfig.name && !opts.indexName?.trim()) {
138
+ thisCommand.setOptionValue('indexName', indexConfig.name);
139
+ }
140
+ if (indexConfig.model && !opts.embedModel?.trim()) {
141
+ thisCommand.setOptionValue('embedModel', indexConfig.model);
142
+ }
143
+ })
127
144
  .action(async (query: string, options: CommandOptions) => {
128
145
  if (options.verbose) {
129
146
  console.log('🔍 Initializing framework...');
@@ -131,13 +148,13 @@ const _command = createCommand('search')
131
148
 
132
149
  const framework = await setupFramework(options);
133
150
 
134
- if (!options.azureSearchIndexName) {
135
- throw new Error('Azure Search index name is required');
151
+ if (!options.indexName) {
152
+ throw new Error('Index name is required');
136
153
  }
137
154
 
138
155
  if (options.verbose) {
139
156
  console.log('✅ Framework initialized successfully');
140
- console.log(`📇 Index: ${options.azureSearchIndexName}`);
157
+ console.log(`📇 Index: ${options.indexName}`);
141
158
  console.log(`🔎 Searching for: "${query}"`);
142
159
  console.log(`📊 Limit: ${options.limit}`);
143
160
  console.log(`🔍 Search type: ${options.searchType}`);
@@ -147,7 +164,7 @@ const _command = createCommand('search')
147
164
  console.log('');
148
165
  }
149
166
 
150
- const vectorStoreService = framework.ai.getService('search', options.azureSearchIndexName);
167
+ const vectorStoreService = framework.ai.useIndex(options.indexName);
151
168
 
152
169
  try {
153
170
  const filter = options.filter ? { filterExpression: options.filter } : undefined;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Known OpenAI embedding model names and their output vector dimensions.
3
+ *
4
+ * Used by the `ffc ai index create` command to set the `dimensions`
5
+ * property on the `content_vector` field in the Azure AI Search schema.
6
+ *
7
+ * @see https://platform.openai.com/docs/guides/embeddings
8
+ */
9
+ const KNOWN_MODEL_DIMENSIONS: ReadonlyMap<string, number> = new Map([
10
+ ['text-embedding-3-large', 3072],
11
+ ['text-embedding-3-small', 1536],
12
+ ['text-embedding-ada-002', 1536],
13
+ ]);
14
+
15
+ /**
16
+ * Resolve the embedding vector dimensions for a given model name.
17
+ *
18
+ * Checks the known model→dimensions map first. Falls back to an explicit
19
+ * `dimensions` override from the config. Throws if neither is available.
20
+ *
21
+ * @param model - The embedding model name (e.g. `'text-embedding-3-large'`).
22
+ * @param configDimensions - Optional explicit dimensions from config, used
23
+ * when the model is not in the known map.
24
+ * @returns The number of dimensions for the embedding vector.
25
+ * @throws {Error} When the model is unknown and no explicit dimensions are configured.
26
+ */
27
+ export function resolveEmbeddingDimensions(model: string, configDimensions?: number): number {
28
+ const known = KNOWN_MODEL_DIMENSIONS.get(model);
29
+ if (known !== undefined) return known;
30
+
31
+ if (configDimensions !== undefined) return configDimensions;
32
+
33
+ const knownModels = [...KNOWN_MODEL_DIMENSIONS.keys()].join(', ');
34
+ throw new Error(
35
+ `Unknown embedding model "${model}". ` +
36
+ `Known models: ${knownModels}. ` +
37
+ 'For custom models, set `index.embedding.dimensions` in the config.',
38
+ );
39
+ }
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { z } from 'zod';
3
+
4
+ import { zodToAzureFields } from './zod-to-azure-fields.js';
5
+
6
+ describe('zodToAzureFields', () => {
7
+ it('maps z.string() to Edm.String with filterable + facetable', () => {
8
+ const schema = z.object({ pkg_name: z.string() });
9
+ const fields = zodToAzureFields(schema);
10
+
11
+ expect(fields).toEqual([
12
+ {
13
+ name: 'pkg_name',
14
+ type: 'Edm.String',
15
+ filterable: true,
16
+ sortable: false,
17
+ facetable: true,
18
+ searchable: false,
19
+ },
20
+ ]);
21
+ });
22
+
23
+ it('maps z.number() to Edm.Double with filterable + sortable', () => {
24
+ const schema = z.object({ score: z.number() });
25
+ const fields = zodToAzureFields(schema);
26
+
27
+ expect(fields).toEqual([
28
+ {
29
+ name: 'score',
30
+ type: 'Edm.Double',
31
+ filterable: true,
32
+ sortable: true,
33
+ facetable: false,
34
+ searchable: false,
35
+ },
36
+ ]);
37
+ });
38
+
39
+ it('maps z.boolean() to Edm.Boolean with filterable', () => {
40
+ const schema = z.object({ active: z.boolean() });
41
+ const fields = zodToAzureFields(schema);
42
+
43
+ expect(fields).toEqual([
44
+ {
45
+ name: 'active',
46
+ type: 'Edm.Boolean',
47
+ filterable: true,
48
+ sortable: false,
49
+ facetable: false,
50
+ searchable: false,
51
+ },
52
+ ]);
53
+ });
54
+
55
+ it('maps z.array(z.string()) to Collection(Edm.String) with filterable + facetable', () => {
56
+ const schema = z.object({ tags: z.array(z.string()) });
57
+ const fields = zodToAzureFields(schema);
58
+
59
+ expect(fields).toEqual([
60
+ {
61
+ name: 'tags',
62
+ type: 'Collection(Edm.String)',
63
+ filterable: true,
64
+ sortable: false,
65
+ facetable: true,
66
+ searchable: false,
67
+ },
68
+ ]);
69
+ });
70
+
71
+ it('maps z.enum() to Edm.String', () => {
72
+ const schema = z.object({ status: z.enum(['draft', 'published']) });
73
+ const fields = zodToAzureFields(schema);
74
+
75
+ expect(fields[0]).toMatchObject({ name: 'status', type: 'Edm.String' });
76
+ });
77
+
78
+ it('unwraps z.optional() to the inner type', () => {
79
+ const schema = z.object({ pkg_name: z.string().optional() });
80
+ const fields = zodToAzureFields(schema);
81
+
82
+ expect(fields[0]).toMatchObject({ name: 'pkg_name', type: 'Edm.String' });
83
+ });
84
+
85
+ it('unwraps z.default() to the inner type', () => {
86
+ const schema = z.object({ tags: z.array(z.string()).default([]) });
87
+ const fields = zodToAzureFields(schema);
88
+
89
+ expect(fields[0]).toMatchObject({ name: 'tags', type: 'Collection(Edm.String)' });
90
+ });
91
+
92
+ it('unwraps z.nullable() to the inner type', () => {
93
+ const schema = z.object({ name: z.string().nullable() });
94
+ const fields = zodToAzureFields(schema);
95
+
96
+ expect(fields[0]).toMatchObject({ name: 'name', type: 'Edm.String' });
97
+ });
98
+
99
+ it('unwraps nested wrappers (optional + default)', () => {
100
+ const schema = z.object({ tags: z.array(z.string()).default([]).optional() });
101
+ const fields = zodToAzureFields(schema);
102
+
103
+ expect(fields[0]).toMatchObject({ name: 'tags', type: 'Collection(Edm.String)' });
104
+ });
105
+
106
+ it('handles multiple fields in a single schema', () => {
107
+ const schema = z.object({
108
+ pkg_name: z.string().optional(),
109
+ type: z.string(),
110
+ tags: z.array(z.string()).default([]),
111
+ source_dir: z.string(),
112
+ });
113
+ const fields = zodToAzureFields(schema);
114
+
115
+ expect(fields).toHaveLength(4);
116
+ expect(fields.map((f) => f.name)).toEqual(['pkg_name', 'type', 'tags', 'source_dir']);
117
+ expect(fields.map((f) => f.type)).toEqual([
118
+ 'Edm.String',
119
+ 'Edm.String',
120
+ 'Collection(Edm.String)',
121
+ 'Edm.String',
122
+ ]);
123
+ });
124
+
125
+ it('throws for unsupported Zod types (z.object)', () => {
126
+ const schema = z.object({ nested: z.object({ a: z.string() }) });
127
+
128
+ expect(() => zodToAzureFields(schema)).toThrow(/Unsupported Zod type/);
129
+ });
130
+
131
+ it('throws for unsupported array element types (z.array(z.number()))', () => {
132
+ const schema = z.object({ nums: z.array(z.number()) });
133
+
134
+ expect(() => zodToAzureFields(schema)).toThrow(/Unsupported array element type/);
135
+ });
136
+ });
@@ -0,0 +1,177 @@
1
+ import {
2
+ type z,
3
+ ZodString,
4
+ ZodNumber,
5
+ ZodBoolean,
6
+ ZodArray,
7
+ ZodEnum,
8
+ ZodOptional,
9
+ ZodDefault,
10
+ ZodNullable,
11
+ type ZodType,
12
+ } from 'zod';
13
+
14
+ /**
15
+ * Azure AI Search EDM (Entity Data Model) type identifiers used in
16
+ * index field definitions.
17
+ */
18
+ type AzureEdmType =
19
+ | 'Edm.String'
20
+ | 'Edm.Int32'
21
+ | 'Edm.Int64'
22
+ | 'Edm.Double'
23
+ | 'Edm.Boolean'
24
+ | 'Collection(Edm.String)';
25
+
26
+ /**
27
+ * Azure AI Search field definition matching the REST API schema for
28
+ * index creation.
29
+ *
30
+ * @see https://learn.microsoft.com/en-us/rest/api/searchservice/indexes/create
31
+ */
32
+ export interface AzureSearchField {
33
+ /** Field name as it appears in the index schema. */
34
+ name: string;
35
+ /** Azure EDM type for the field. */
36
+ type: AzureEdmType;
37
+ /** Whether the field can be used in `$filter` expressions. */
38
+ filterable: boolean;
39
+ /** Whether the field can be used in `$orderby` expressions. */
40
+ sortable: boolean;
41
+ /** Whether the field supports faceted navigation. */
42
+ facetable: boolean;
43
+ /** Whether the field is included in full-text search. */
44
+ searchable: boolean;
45
+ }
46
+
47
+ /**
48
+ * Unwrap wrapper types (optional, default, nullable) to reach the
49
+ * underlying concrete Zod type.
50
+ *
51
+ * Uses public `instanceof` checks and `unwrap()` methods to avoid
52
+ * reliance on Zod's private `_zod.def` internals.
53
+ *
54
+ * @param schema - A Zod schema node, possibly wrapped.
55
+ * @returns The innermost non-wrapper schema.
56
+ */
57
+ function unwrapSchema(schema: ZodType): ZodType {
58
+ let current: ZodType = schema;
59
+ // Walk through wrapper layers until we reach a concrete type
60
+ while (
61
+ current instanceof ZodOptional ||
62
+ current instanceof ZodDefault ||
63
+ current instanceof ZodNullable
64
+ ) {
65
+ current = current.unwrap() as ZodType;
66
+ }
67
+ return current;
68
+ }
69
+
70
+ /**
71
+ * Map a concrete (unwrapped) Zod schema to its Azure EDM field type.
72
+ *
73
+ * Uses public `instanceof` checks against Zod's exported class hierarchy
74
+ * for forward-compatible type mapping.
75
+ *
76
+ * @param schema - The unwrapped Zod schema node.
77
+ * @returns The corresponding Azure EDM type string.
78
+ * @throws {Error} When the Zod type cannot be mapped to an Azure EDM type.
79
+ */
80
+ function zodToEdmType(schema: ZodType): AzureEdmType {
81
+ if (schema instanceof ZodString || schema instanceof ZodEnum) {
82
+ return 'Edm.String';
83
+ }
84
+ if (schema instanceof ZodNumber) {
85
+ return 'Edm.Double';
86
+ }
87
+ if (schema instanceof ZodBoolean) {
88
+ return 'Edm.Boolean';
89
+ }
90
+ if (schema instanceof ZodArray) {
91
+ const elementSchema = unwrapSchema(schema.element as ZodType);
92
+ if (elementSchema instanceof ZodString || elementSchema instanceof ZodEnum) {
93
+ return 'Collection(Edm.String)';
94
+ }
95
+ throw new Error(
96
+ `Unsupported array element type "${elementSchema.constructor.name}". Only string arrays are supported for Azure Search fields.`,
97
+ );
98
+ }
99
+ throw new Error(
100
+ `Unsupported Zod type "${schema.constructor.name}" for Azure Search field mapping. ` +
101
+ 'Supported types: ZodString, ZodNumber, ZodBoolean, ZodEnum, ZodArray(ZodString).',
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Derive default field capabilities from an Azure EDM type.
107
+ *
108
+ * All promoted fields are filterable. Strings and string collections are
109
+ * also facetable. Numbers are sortable.
110
+ *
111
+ * @param edmType - The Azure EDM type of the field.
112
+ * @returns Default capability flags for the field.
113
+ */
114
+ function defaultCapabilities(
115
+ edmType: AzureEdmType,
116
+ ): Pick<AzureSearchField, 'filterable' | 'sortable' | 'facetable' | 'searchable'> {
117
+ switch (edmType) {
118
+ case 'Edm.String':
119
+ return { filterable: true, sortable: false, facetable: true, searchable: false };
120
+ case 'Collection(Edm.String)':
121
+ return { filterable: true, sortable: false, facetable: true, searchable: false };
122
+ case 'Edm.Double':
123
+ case 'Edm.Int32':
124
+ case 'Edm.Int64':
125
+ return { filterable: true, sortable: true, facetable: false, searchable: false };
126
+ case 'Edm.Boolean':
127
+ return { filterable: true, sortable: false, facetable: false, searchable: false };
128
+ default:
129
+ return { filterable: true, sortable: false, facetable: false, searchable: false };
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Convert a Zod object schema into an array of Azure AI Search field
135
+ * definitions.
136
+ *
137
+ * Walks the Zod shape, maps each field to its Azure EDM type, and assigns
138
+ * default capabilities (filterable, facetable, sortable). Used by the
139
+ * `ffc ai index create` command to generate the index schema.
140
+ *
141
+ * Uses public `instanceof` checks and `unwrap()` methods to avoid
142
+ * reliance on Zod's private `_zod.def` internals, ensuring compatibility
143
+ * across Zod versions.
144
+ *
145
+ * @param schema - A Zod object schema whose keys define the promoted fields.
146
+ * @returns An array of Azure AI Search field definitions.
147
+ * @throws {Error} When a field type cannot be mapped to an Azure EDM type.
148
+ *
149
+ * @example
150
+ * ```ts
151
+ * import { z } from 'zod';
152
+ * import { zodToAzureFields } from './zod-to-azure-fields.js';
153
+ *
154
+ * const fields = zodToAzureFields(
155
+ * z.object({
156
+ * pkg_name: z.string().optional(),
157
+ * tags: z.array(z.string()).default([]),
158
+ * }),
159
+ * );
160
+ * // [
161
+ * // { name: 'pkg_name', type: 'Edm.String', filterable: true, facetable: true, ... },
162
+ * // { name: 'tags', type: 'Collection(Edm.String)', filterable: true, facetable: true, ... },
163
+ * // ]
164
+ * ```
165
+ */
166
+ export function zodToAzureFields(schema: z.ZodObject): AzureSearchField[] {
167
+ const shape = schema.shape as Record<string, ZodType>;
168
+
169
+ return Object.entries(shape).map(([name, fieldSchema]) => {
170
+ // Unwrap wrapper types to reach the concrete type
171
+ const innerSchema = unwrapSchema(fieldSchema);
172
+ const edmType = zodToEdmType(innerSchema);
173
+ const capabilities = defaultCapabilities(edmType);
174
+
175
+ return { name, type: edmType, ...capabilities };
176
+ });
177
+ }
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by genversion.
2
- export const version = '2.0.1';
2
+ export const version = '3.0.0';