Result anatomy
What a search returns — the documents, the facet counts, and the one field to check before trusting an empty result.
A search response carries the documents and the facet counts in one object. There is no second request for aggregations, and no assembling to do on your side.
Check $error first
An empty result has two very different causes, and they arrive identically: no records, rowCount: 0.
if (result.$error) {
// The request failed. Nothing else on this object was received.
showError(result.$error.message);
} else if (result.rowCount === 0) {
// The corpus legitimately matched nothing.
showEmptyState();
}$error is set by the client and never by the server — it exists precisely so the two can be told apart. Code
that reads only records reports "no results" for a backend answering 500 on a malformed filter, which is
the hardest kind of bug to notice.
The response
Documents and counts
Prop
Type
Facets, tabs and sorting
Prop
Type
Language features
Present only when the query web service is configured for them, which is why most are optional.
Prop
Type
Article — one document
Article declares the columns every Sinequa index has:
| Group | Fields |
|---|---|
| Identity | id, rank, databasealias, containerid |
| Display | title, displayTitle (with highlight markup), filename, fileext, docformat, doctype, size, modified, treepath, authors, collection, connector, flags |
| Links | url1, url2, originalUrl, pdfUrl, thumbnailUrl |
| Relevance | globalrelevance, documentweight, relevantExtracts (HTML), extracts, matchlocations, matchingpartnames, termspresence |
| Neural | matchingpassages, $hasPassages |
| Security | accesslists |
| Duplicates | nearhash, exacthash, groupcount, $isDuplicate, $duplicateCount |
| Client-side | $selected, $page |
Everything else your index holds is not on the type. Custom metadata — sourcestr1, a mapped
department — exists on the object but is not declared, and its value may be a string, a comma-separated
string, an array, or an array of { display, value } objects depending on the column.
getMetadata normalises all of those to string[]:
import { createAtomicClient, getMetadata, getMetadataWithValues } from "@sinequa/atomic";
import type { Article, Result } from "@sinequa/atomic";
const client = createAtomicClient({
app: "<your-app>",
backendUrl: "https://<your-sinequa-server>",
});
await client.initializeConfig();
await client.auth.login();
const result: Result = await client.api.query.search({
name: "<your-query>",
text: "quarterly report",
pageSize: 10,
});
if (result.$error) throw result.$error;
for (const record of result.records) {
console.log(describe(record));
}
function describe(record: Article) {
// Declared columns: read them directly.
const parts = [record.title, record.docformat, `${record.size} bytes`];
// Custom columns: always a string[], whatever shape the server sent.
const departments = getMetadata(record, "department");
if (departments.length) parts.push(departments.join(" / "));
// When you need the value behind the label — to filter on it, typically.
for (const { display, value } of getMetadataWithValues(record, "department")) {
console.log(` facet on ${value}, shown as ${display}`);
}
// The highlighted snippet is HTML: render it as such, or strip it.
if (record.relevantExtracts) parts.push(record.relevantExtracts);
return parts.join(" — ");
}import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
import { getMetadata } from "@sinequa/atomic";
import type { Article } from "@sinequa/atomic";
@Component({
selector: "app-hit",
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<article>
<!-- displayTitle carries highlight markup; title is plain text. -->
<h3 [innerHTML]="record().displayTitle ?? record().title"></h3>
<p>
{{ record().docformat }} · {{ record().modified | date }}
@if (departments().length) {
· {{ departments().join(' / ') }}
}
</p>
@if (record().relevantExtracts) {
<p class="extract" [innerHTML]="record().relevantExtracts"></p>
}
</article>
`,
})
export class HitComponent {
readonly record = input.required<Article>();
/** Custom columns are not on Article, and their shape varies. */
readonly departments = computed(() => getMetadata(this.record(), "department"));
}import { getMetadata } from "@sinequa/atomic";
import type { Article } from "@sinequa/atomic";
export function Hit({ record }: { record: Article }) {
// Custom columns are not on Article, and their shape varies.
const departments = getMetadata(record, "department");
return (
<article>
{/* displayTitle carries highlight markup; title is plain text. */}
<h3 dangerouslySetInnerHTML={{ __html: record.displayTitle ?? record.title }} />
<p>
{record.docformat} · {new Date(record.modified).toLocaleDateString()}
{departments.length > 0 && ` · ${departments.join(" / ")}`}
</p>
{record.relevantExtracts && (
<p className="extract" dangerouslySetInnerHTML={{ __html: record.relevantExtracts }} />
)}
</article>
);
}AggregateResult — a different response
A query sent with action: "aggregate" answers with four keys and nothing else:
type AggregateResult = {
id: string;
queryName: string;
aggregations: (Aggregation | TreeAggregation)[];
attributes: Attributes;
};No records, no rowCount, no page, no tab. Casting one to Result makes twelve absent fields look
present, and arithmetic on them yields NaN — where every comparison is false, so a response with no
pagination reads exactly like one on its last page.
The $ convention
A $-prefixed field is added by the client, never sent by the server:
| Field | On | Meaning |
|---|---|---|
$error | Result | The request failed; nothing else was received. |
$selected | Article, AggregationItem | Your selection state, for you to maintain. |
$page | Article | Which page this document came from, when accumulating pages. |
$hasMore | Aggregation | More items are available than were returned. |
$path, $level, $opened | TreeAggregationNode | Computed when a tree is expanded. |
$isDuplicate, $duplicateCount | Article | Duplicate grouping. |
$hasPassages | Article | Whether neural passages were attached. |