diff --git a/docs/reference/search-api-graphql.md b/docs/reference/search-api-graphql.md index 75842c56..fb1a2332 100644 --- a/docs/reference/search-api-graphql.md +++ b/docs/reference/search-api-graphql.md @@ -260,3 +260,45 @@ or a new version of this library emitting different GraphQL for the same declaration – fails the test and shows the SDL diff, until you consciously accept it (`vitest -u`) and the reviewer sees the contract change spelled out in the PR. + +### Committing the contract as a file + +A snapshot guards the contract inside the test suite. A deployment that mounts +a [schema-declaration module](./search-api-server#the-schema-module) usually +wants the contract as a **published file** instead – `schema.graphql`, the +thing its consumers read and its pull requests diff. The `search-print-sdl` +bin writes it: + +```sh +search-print-sdl --module ./dist/module.js --out ./schema.graphql +``` + +It loads the module the way the indexer and the served API load it (same +validation, same `schemaOptions` forwarding), so the file cannot describe a +different API from the one served. Regenerate it in CI and commit the +difference; a pull request that moves the surface then shows the move. + +Without `--out` the SDL goes to standard output. The same thing from code – a +separate entry point, because it reads the filesystem and the main one stays +runtime-agnostic: + +```ts +import { printSchemaModuleSdl } from '@lde/search-api-graphql/print-sdl'; + +await printSchemaModuleSdl({ + modulePath: './dist/module.js', + outputPath: './schema.graphql', +}); +``` + +#### Formatting + +The output is formatted with the **Prettier configuration that applies to the +output path**, because a repository whose pre-commit hook formats every staged +file would otherwise have the hook and this writer spell the same schema +differently and overwrite each other in turn. It also keeps a surface move +readable: one field argument per line, so adding an argument is one added line. + +Prettier is an **optional peer dependency** – your own version formats the +file, which is the point. Pass `--no-format` (or `format: false`) to write the +SDL exactly as GraphQL prints it, and Prettier is never loaded. diff --git a/package-lock.json b/package-lock.json index 946c66ef..c292cdb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34004,7 +34004,7 @@ "version": "3.9.6", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -41284,7 +41284,7 @@ }, "packages/pipeline": { "name": "@lde/pipeline", - "version": "0.35.4", + "version": "0.35.6", "license": "MIT", "dependencies": { "@lde/dataset": "^0.7.9", @@ -41310,7 +41310,7 @@ }, "packages/pipeline-console-reporter": { "name": "@lde/pipeline-console-reporter", - "version": "0.26.4", + "version": "0.26.6", "license": "MIT", "dependencies": { "chalk": "^5.4.1", @@ -41321,7 +41321,7 @@ }, "peerDependencies": { "@lde/dataset": "^0.7.9", - "@lde/pipeline": "^0.35.4" + "@lde/pipeline": "^0.35.6" } }, "packages/pipeline-console-reporter/node_modules/ansi-regex": { @@ -41501,7 +41501,7 @@ }, "packages/pipeline-shacl-sampler": { "name": "@lde/pipeline-shacl-sampler", - "version": "0.9.4", + "version": "0.9.6", "license": "MIT", "dependencies": { "@rdfjs/types": "^2.0.1", @@ -41512,7 +41512,7 @@ }, "peerDependencies": { "@lde/dataset": "^0.7.9", - "@lde/pipeline": "^0.35.4" + "@lde/pipeline": "^0.35.6" } }, "packages/pipeline-shacl-sampler/node_modules/n3": { @@ -41530,7 +41530,7 @@ }, "packages/pipeline-shacl-validator": { "name": "@lde/pipeline-shacl-validator", - "version": "0.18.4", + "version": "0.18.6", "license": "MIT", "dependencies": { "@rdfjs/types": "^2.0.1", @@ -41544,7 +41544,7 @@ }, "peerDependencies": { "@lde/dataset": "^0.7.9", - "@lde/pipeline": "^0.35.4" + "@lde/pipeline": "^0.35.6" } }, "packages/pipeline-shacl-validator/node_modules/n3": { @@ -41563,7 +41563,7 @@ }, "packages/pipeline-void": { "name": "@lde/pipeline-void", - "version": "0.34.0", + "version": "0.34.2", "license": "MIT", "dependencies": { "@rdfjs/types": "^2.0.1", @@ -41574,7 +41574,7 @@ }, "peerDependencies": { "@lde/dataset": "^0.7.9", - "@lde/pipeline": "^0.35.4" + "@lde/pipeline": "^0.35.6" } }, "packages/pipeline-void/node_modules/n3": { @@ -41605,7 +41605,7 @@ }, "packages/search": { "name": "@lde/search", - "version": "0.15.0", + "version": "0.18.1", "license": "MIT", "dependencies": { "@lde/text-normalization": "^0.1.2", @@ -41630,18 +41630,39 @@ }, "packages/search-api-graphql": { "name": "@lde/search-api-graphql", - "version": "0.16.0", + "version": "0.20.1", "license": "MIT", "dependencies": { "@escape.tech/graphql-armor-cost-limit": "^2.4.3", "@escape.tech/graphql-armor-max-depth": "^2.4.2", "@graphql-yoga/render-graphiql": "^5.21.2", - "@lde/search": "^0.15.0", + "@lde/search": "^0.18.1", + "commander": "^15.0.0", "dataloader": "^2.2.3", - "graphql": "^16.0.0", + "graphql": "^16.9.0", "graphql-yoga": "^5.21.2", "negotiator": "^1.0.0", "tslib": "^2.3.0" + }, + "bin": { + "search-print-sdl": "dist/cli.js" + }, + "peerDependencies": { + "prettier": "^3.0.0" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "packages/search-api-graphql/node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" } }, "packages/search-api-graphql/node_modules/negotiator": { @@ -41655,12 +41676,12 @@ }, "packages/search-api-server": { "name": "@lde/search-api-server", - "version": "0.4.0", + "version": "0.8.1", "license": "MIT", "dependencies": { - "@lde/search": "^0.15.0", - "@lde/search-api-graphql": "^0.16.0", - "@lde/search-typesense": "^0.18.0", + "@lde/search": "^0.18.1", + "@lde/search-api-graphql": "^0.20.1", + "@lde/search-typesense": "^0.21.1", "@whatwg-node/server": "^0.11.0", "tslib": "^2.3.0", "typesense": "^3.0.6" @@ -41671,15 +41692,15 @@ }, "packages/search-indexer": { "name": "@lde/search-indexer", - "version": "0.3.0", + "version": "0.6.2", "license": "MIT", "dependencies": { "@lde/dataset-registry-client": "^0.9.1", - "@lde/pipeline": "^0.35.4", - "@lde/pipeline-console-reporter": "^0.26.4", - "@lde/search": "^0.15.0", - "@lde/search-pipeline": "^0.15.0", - "@lde/search-typesense": "^0.18.0", + "@lde/pipeline": "^0.35.6", + "@lde/pipeline-console-reporter": "^0.26.6", + "@lde/search": "^0.18.1", + "@lde/search-pipeline": "^0.18.2", + "@lde/search-typesense": "^0.21.1", "@lde/sparql-qlever": "^0.15.3", "commander": "^15.0.0", "tslib": "^2.3.0", @@ -41700,17 +41721,17 @@ }, "packages/search-pipeline": { "name": "@lde/search-pipeline", - "version": "0.15.0", + "version": "0.18.2", "license": "MIT", "dependencies": { - "@lde/search": "^0.15.0", + "@lde/search": "^0.18.1", "@traqula/generator-sparql-1-1": "^1.1.8", "@traqula/parser-sparql-1-1": "^1.1.5", "@traqula/rules-sparql-1-1": "^1.1.0", "tslib": "^2.3.0" }, "devDependencies": { - "@lde/search-typesense": "^0.18.0", + "@lde/search-typesense": "^0.21.1", "@rdfjs/types": "^2.0.1", "n3": "^2.1.2", "testcontainers": "^12.0.3", @@ -41718,7 +41739,7 @@ }, "peerDependencies": { "@lde/dataset": "^0.7.9", - "@lde/pipeline": "^0.35.4" + "@lde/pipeline": "^0.35.6" } }, "packages/search-pipeline/node_modules/n3": { @@ -41737,10 +41758,10 @@ }, "packages/search-typesense": { "name": "@lde/search-typesense", - "version": "0.18.0", + "version": "0.21.1", "license": "MIT", "dependencies": { - "@lde/search": "^0.15.0", + "@lde/search": "^0.18.1", "@lde/text-normalization": "^0.1.2", "tslib": "^2.3.0", "typesense": "^3.0.6" @@ -41750,7 +41771,7 @@ }, "peerDependencies": { "@lde/dataset": "^0.7.9", - "@lde/pipeline": "^0.35.4" + "@lde/pipeline": "^0.35.6" } }, "packages/search/node_modules/n3": { diff --git a/packages/search-api-graphql/package.json b/packages/search-api-graphql/package.json index 1f36ed39..a0e0f448 100644 --- a/packages/search-api-graphql/package.json +++ b/packages/search-api-graphql/package.json @@ -15,11 +15,20 @@ "import": "./dist/index.js", "development": "./src/index.ts", "default": "./dist/index.js" + }, + "./print-sdl": { + "types": "./dist/print-sdl.d.ts", + "import": "./dist/print-sdl.js", + "development": "./src/print-sdl.ts", + "default": "./dist/print-sdl.js" } }, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "search-print-sdl": "dist/cli.js" + }, "files": [ "dist", "!**/*.tsbuildinfo" @@ -29,10 +38,19 @@ "@escape.tech/graphql-armor-max-depth": "^2.4.2", "@graphql-yoga/render-graphiql": "^5.21.2", "@lde/search": "^0.18.1", + "commander": "^15.0.0", "dataloader": "^2.2.3", "graphql": "^16.9.0", "graphql-yoga": "^5.21.2", "negotiator": "^1.0.0", "tslib": "^2.3.0" + }, + "peerDependencies": { + "prettier": "^3.0.0" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } } } diff --git a/packages/search-api-graphql/src/cli.ts b/packages/search-api-graphql/src/cli.ts new file mode 100644 index 00000000..d7d025cc --- /dev/null +++ b/packages/search-api-graphql/src/cli.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs'; +import { Command } from 'commander'; +import { printSchemaModuleSdl } from './print-sdl.js'; + +const { version } = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +) as { version: string }; + +const program = new Command() + .name('search-print-sdl') + .description( + 'Print the GraphQL contract of a mounted @lde/search schema-declaration module: the published surface consumers meet. Commit the output so every pull request that moves the surface shows the move.', + ) + .version(version) + .requiredOption( + '--module ', + 'path to the schema-declaration module, the same file the indexer and the served API mount', + ) + .option('--out ', 'file to write the SDL to (default: standard output)') + .option( + '--no-format', + 'write the SDL as GraphQL prints it, instead of formatting it with the Prettier configuration that applies to the output file', + ); + +program.parse(); + +const options = program.opts<{ + module: string; + out?: string; + format: boolean; +}>(); + +try { + const sdl = await printSchemaModuleSdl({ + modulePath: options.module, + outputPath: options.out, + format: options.format, + }); + if (options.out === undefined) { + process.stdout.write(sdl); + } else { + console.info(`Wrote ${options.out}`); + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/packages/search-api-graphql/src/print-sdl.ts b/packages/search-api-graphql/src/print-sdl.ts new file mode 100644 index 00000000..7b63f957 --- /dev/null +++ b/packages/search-api-graphql/src/print-sdl.ts @@ -0,0 +1,85 @@ +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { loadSchemaModule, optionalObjectExport } from '@lde/search/module'; +import { printGraphQLSchema } from './build-schema.js'; +import type { BuildGraphQLSchemaOptions } from './build-schema.js'; + +/** What {@link printSchemaModuleSdl} needs to know. */ +export interface PrintSchemaModuleSdlOptions { + /** Path to the mounted schema-declaration module – the same file the + * indexer and the served API mount, so the printed contract cannot describe + * a different API from the one served. Its optional `schemaOptions` export + * is forwarded to {@link printGraphQLSchema}. */ + readonly modulePath: string; + /** Where to write the SDL. Omit to only return it. */ + readonly outputPath?: string; + /** Format the SDL with Prettier, resolving the config that applies to + * {@link outputPath} (default `true`). Keeps the file byte-identical to what + * a repo’s own formatter would produce, so a formatting pre-commit hook and + * this writer cannot spell the same schema differently and overwrite each + * other in turn. Requires `prettier` to be installed alongside this package. + */ + readonly format?: boolean; +} + +/** + * Print the GraphQL contract of a mounted schema-declaration module, and + * optionally write it to a file – the published surface consumers meet, which + * a repository commits so every pull request that moves it shows the move. + * + * Returns the SDL. Throws with the module path in the message for every + * failure mode of the module itself (unreadable, wrong export shape, invalid + * declaration), so a broken contract build names the file to fix. + */ +export async function printSchemaModuleSdl({ + modulePath, + outputPath, + format = true, +}: PrintSchemaModuleSdlOptions): Promise { + const { schema, moduleExports } = await loadSchemaModule(modulePath); + const sdl = printGraphQLSchema( + schema, + optionalObjectExport( + moduleExports, + 'schemaOptions', + modulePath, + ), + ); + // Prettier resolves its config from the file it is about to format, so a + // stdout run still has to name the path the SDL would live at. + const filepath = resolve(outputPath ?? 'schema.graphql'); + const output = format ? await formatWithPrettier(sdl, filepath) : sdl; + if (outputPath !== undefined) { + await writeFile(filepath, output, 'utf8'); + } + return output; +} + +/** Prettier is an optional peer: the consumer’s own version formats the file, + * because matching the consumer’s repository is the whole point. */ +async function formatWithPrettier( + sdl: string, + filepath: string, +): Promise { + let prettier: typeof import('prettier'); + try { + prettier = await import('prettier'); + } catch (cause) { + // Reports what went wrong rather than assuming it is missing: a broken + // install and a plugin that throws at module scope both land here, and + // “install prettier” is unhelpful advice for either. + throw new Error( + `Formatting the SDL requires “prettier”, an optional peer dependency of @lde/search-api-graphql, which could not be loaded: ${String(cause)}. Install it, or turn formatting off.`, + { cause }, + ); + } + return prettier.format(sdl, { + // The CLI reads `.editorconfig` by default and the Node API does not, so + // leaving it off would have `prettier --write` reformat what we just wrote. + ...(await prettier.resolveConfig(filepath, { editorconfig: true })), + filepath, + // Named outright, so an output path Prettier cannot map to a parser (say + // `.sdl`) formats instead of failing on an inferred-parser error. + parser: 'graphql', + }); +} diff --git a/packages/search-api-graphql/test/fixtures/invalid-options.mjs b/packages/search-api-graphql/test/fixtures/invalid-options.mjs new file mode 100644 index 00000000..e25bc84d --- /dev/null +++ b/packages/search-api-graphql/test/fixtures/invalid-options.mjs @@ -0,0 +1,10 @@ +/** A schema-declaration module whose `schemaOptions` is not an object. */ +export default [ + { + name: 'Dataset', + class: 'http://www.w3.org/ns/dcat#Dataset', + fields: [{ name: 'title', kind: 'text', locales: ['en'], output: true }], + }, +]; + +export const schemaOptions = 'nope'; diff --git a/packages/search-api-graphql/test/fixtures/no-options.mjs b/packages/search-api-graphql/test/fixtures/no-options.mjs new file mode 100644 index 00000000..8e849e98 --- /dev/null +++ b/packages/search-api-graphql/test/fixtures/no-options.mjs @@ -0,0 +1,8 @@ +/** A schema-declaration module without a `schemaOptions` export. */ +export default [ + { + name: 'Dataset', + class: 'http://www.w3.org/ns/dcat#Dataset', + fields: [{ name: 'title', kind: 'text', locales: ['en'], output: true }], + }, +]; diff --git a/packages/search-api-graphql/test/fixtures/search-schema.mjs b/packages/search-api-graphql/test/fixtures/search-schema.mjs new file mode 100644 index 00000000..48f4b6cf --- /dev/null +++ b/packages/search-api-graphql/test/fixtures/search-schema.mjs @@ -0,0 +1,30 @@ +/** + * A minimal schema-declaration module, shaped exactly as a deployment mounts + * it: plain data, no imports. Its `schemaOptions` renames the root query + * field, so the printed SDL shows whether the options were forwarded. + */ +export default [ + { + name: 'Dataset', + class: 'http://www.w3.org/ns/dcat#Dataset', + fields: [ + { + name: 'title', + kind: 'text', + locales: ['nl', 'en'], + output: true, + searchable: { weight: 5 }, + }, + { + name: 'keyword', + kind: 'keyword', + array: true, + facetable: true, + filterable: true, + output: true, + }, + ], + }, +]; + +export const schemaOptions = { types: { Dataset: { queryField: 'catalogue' } } }; diff --git a/packages/search-api-graphql/test/print-sdl.test.ts b/packages/search-api-graphql/test/print-sdl.test.ts new file mode 100644 index 00000000..9fdc93d6 --- /dev/null +++ b/packages/search-api-graphql/test/print-sdl.test.ts @@ -0,0 +1,132 @@ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { printSchemaModuleSdl } from '../src/print-sdl.js'; + +const fixture = (name: string): string => + fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); + +let directory: string; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'lde-print-sdl-')); +}); + +afterEach(() => { + vi.doUnmock('prettier'); +}); + +describe('printSchemaModuleSdl', () => { + it('prints the contract of the mounted module', async () => { + const sdl = await printSchemaModuleSdl({ + modulePath: fixture('no-options.mjs'), + }); + + expect(sdl).toContain('type Query {'); + expect(sdl).toContain('datasets('); + }); + + it('forwards the module’s schemaOptions', async () => { + const sdl = await printSchemaModuleSdl({ + modulePath: fixture('search-schema.mjs'), + }); + + expect(sdl).toContain('catalogue('); + expect(sdl).not.toContain('datasets('); + }); + + it('writes the SDL to the output path', async () => { + const outputPath = join(directory, 'schema.graphql'); + + const sdl = await printSchemaModuleSdl({ + modulePath: fixture('no-options.mjs'), + outputPath, + }); + + await expect(readFile(outputPath, 'utf8')).resolves.toBe(sdl); + }); + + it('formats with the Prettier configuration that applies to the output', async () => { + await writeFile( + join(directory, '.prettierrc'), + JSON.stringify({ tabWidth: 4 }), + 'utf8', + ); + + const sdl = await printSchemaModuleSdl({ + modulePath: fixture('no-options.mjs'), + outputPath: join(directory, 'schema.graphql'), + }); + + expect(sdl).toContain('\n datasets('); + }); + + it('honours .editorconfig, as the Prettier CLI does', async () => { + await writeFile( + join(directory, '.editorconfig'), + '[*]\nindent_size = 8\n', + 'utf8', + ); + + const sdl = await printSchemaModuleSdl({ + modulePath: fixture('no-options.mjs'), + outputPath: join(directory, 'schema.graphql'), + }); + + expect(sdl).toContain('\n datasets('); + }); + + it('formats an output path Prettier cannot infer a parser from', async () => { + const sdl = await printSchemaModuleSdl({ + modulePath: fixture('no-options.mjs'), + outputPath: join(directory, 'schema.sdl'), + }); + + expect(sdl).toContain('type Query {'); + }); + + it('leaves the SDL as GraphQL prints it when formatting is off', async () => { + const unformatted = await printSchemaModuleSdl({ + modulePath: fixture('no-options.mjs'), + format: false, + }); + const formatted = await printSchemaModuleSdl({ + modulePath: fixture('no-options.mjs'), + }); + + // Prettier breaks the multi-argument root field over several lines, so a + // surface move stays one added line in the committed diff. + expect(unformatted).toContain(' datasets(query: String'); + expect(formatted).not.toBe(unformatted); + }); + + it('rejects a non-object schemaOptions, naming the module', async () => { + await expect( + printSchemaModuleSdl({ modulePath: fixture('invalid-options.mjs') }), + ).rejects.toThrowError( + /invalid-options\.mjs” export “schemaOptions” must be an object/, + ); + }); + + it('rejects a missing module, naming the path', async () => { + await expect( + printSchemaModuleSdl({ modulePath: '/no/such/module.mjs' }), + ).rejects.toThrowError( + /Cannot load schema module “\/no\/such\/module\.mjs”/, + ); + }); + + it('names the optional Prettier peer when it cannot be loaded', async () => { + vi.doMock('prettier', () => { + throw new Error('Cannot find package ‘prettier’'); + }); + + await expect( + printSchemaModuleSdl({ modulePath: fixture('no-options.mjs') }), + ).rejects.toThrowError( + /Formatting the SDL requires “prettier”, an optional peer dependency .*, which could not be loaded: /, + ); + }); +}); diff --git a/packages/search-api-graphql/vite.config.ts b/packages/search-api-graphql/vite.config.ts index 748b3c22..941c1afb 100644 --- a/packages/search-api-graphql/vite.config.ts +++ b/packages/search-api-graphql/vite.config.ts @@ -17,12 +17,13 @@ export default mergeConfig( }, test: { coverage: { + exclude: ['src/cli.ts', 'test/fixtures/**'], thresholds: { functions: 100, lines: 100, // Full-suite baseline, re-anchored when covered branches are // deleted (autoUpdate only ever raises; see AGENTS.md). - branches: 95.42, + branches: 95.62, statements: 100, }, }, diff --git a/packages/search-api-server/src/schema-module.ts b/packages/search-api-server/src/schema-module.ts index 489017f0..7e4f1ef6 100644 --- a/packages/search-api-server/src/schema-module.ts +++ b/packages/search-api-server/src/schema-module.ts @@ -1,4 +1,7 @@ -import { loadSchemaModule as loadDeclarations } from '@lde/search/module'; +import { + loadSchemaModule as loadDeclarations, + optionalObjectExport, +} from '@lde/search/module'; import type { SearchSchema } from '@lde/search'; import type { BuildGraphQLSchemaOptions } from '@lde/search-api-graphql'; import type { TypesenseSearchEngineOptions } from '@lde/search-typesense'; @@ -39,24 +42,15 @@ export async function loadSchemaModule( const { schema, moduleExports } = await loadDeclarations(modulePath); return { searchSchema: schema, - schemaOptions: optionalObject(moduleExports, 'schemaOptions', modulePath), - engineOptions: optionalObject(moduleExports, 'engineOptions', modulePath), + schemaOptions: optionalObjectExport( + moduleExports, + 'schemaOptions', + modulePath, + ), + engineOptions: optionalObjectExport( + moduleExports, + 'engineOptions', + modulePath, + ), }; } - -function optionalObject( - moduleExports: Record, - name: string, - modulePath: string, -): Options | undefined { - const value = moduleExports[name]; - if (value === undefined) { - return undefined; - } - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new Error( - `Schema module “${modulePath}” export “${name}” must be an object.`, - ); - } - return value as Options; -} diff --git a/packages/search-api-server/vite.config.ts b/packages/search-api-server/vite.config.ts index dea75b5c..35c516be 100644 --- a/packages/search-api-server/vite.config.ts +++ b/packages/search-api-server/vite.config.ts @@ -21,7 +21,7 @@ export default mergeConfig( autoUpdate: true, functions: 100, lines: 100, - branches: 97.56, + branches: 97.05, statements: 100, }, }, diff --git a/packages/search/src/module.ts b/packages/search/src/module.ts index 43d741ad..80e52679 100644 --- a/packages/search/src/module.ts +++ b/packages/search/src/module.ts @@ -60,6 +60,30 @@ export async function loadSchemaModule( } } +/** + * Read an optional object-shaped export of a loaded schema module, rejecting + * anything that is not a plain object with the module path and the export name + * in the message. Which optional exports a module may carry is the consumer’s + * business – {@link loadSchemaModule} hands back the raw exports for exactly + * that reason – but every consumer rejects a malformed one the same way. + */ +export function optionalObjectExport( + moduleExports: Record, + name: string, + modulePath: string, +): Options | undefined { + const value = moduleExports[name]; + if (value === undefined) { + return undefined; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error( + `Schema module “${modulePath}” export “${name}” must be an object.`, + ); + } + return value as Options; +} + function messageOf(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause); } diff --git a/packages/search/test/module.test.ts b/packages/search/test/module.test.ts index a6786f00..8dfbd261 100644 --- a/packages/search/test/module.test.ts +++ b/packages/search/test/module.test.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { loadSchemaModule } from '../src/module.js'; +import { loadSchemaModule, optionalObjectExport } from '../src/module.js'; const fixture = (name: string): string => fileURLToPath(new URL(`./fixtures/module/${name}`, import.meta.url)); @@ -46,3 +46,39 @@ describe('loadSchemaModule', () => { ).rejects.toThrowError(/declares an invalid schema/); }); }); + +describe('optionalObjectExport', () => { + const modulePath = '/mounted/module.mjs'; + + it('returns the export', () => { + expect( + optionalObjectExport( + { schemaOptions: { maxPerPage: 50 } }, + 'schemaOptions', + modulePath, + ), + ).toEqual({ maxPerPage: 50 }); + }); + + it('returns undefined when the module does not carry the export', () => { + expect( + optionalObjectExport({}, 'schemaOptions', modulePath), + ).toBeUndefined(); + }); + + it.each([ + ['a string', 'nope'], + ['null', null], + ['an array', []], + ])('rejects %s, naming the module and the export', (_, value) => { + expect(() => + optionalObjectExport( + { schemaOptions: value }, + 'schemaOptions', + modulePath, + ), + ).toThrowError( + /Schema module “\/mounted\/module\.mjs” export “schemaOptions” must be an object/, + ); + }); +}); diff --git a/packages/search/vite.config.ts b/packages/search/vite.config.ts index 452cd0cc..34d009f3 100644 --- a/packages/search/vite.config.ts +++ b/packages/search/vite.config.ts @@ -12,7 +12,7 @@ export default mergeConfig( thresholds: { functions: 100, lines: 100, - branches: 99.4, + branches: 99.41, statements: 100, }, },