perf(search): load the results page on demand; lighter preview; lazy chunk budgets
Measured on the static export after merging main (a724fc1), against main:
total JS was 1067.0 KB (main 1005.4), over the 1050 KB cap.
- /app/search loads SearchPage through import(), so the results page is no
longer in any route's first load (/app/search first load 334.5 KB, main
335.2).
- The record preview renders values itself (file thumbnails, chips, Yes/No,
dates, links, text) instead of through FieldValue, whose widget imports
pulled two re-bundled chunks (+17.1 KB) into the preview. Secrets
(REVEAL), widgets, code, HTML and binary fields are not previewed.
- The record search response schemas move from the shared schemas module to
record-search.ts, their only user.
- perf-budget.json budgets the four on-demand search chunks under
lazyChunks with their measured sizes (search page 13.6, quick search panel
9.7, palette record groups 7.8, record preview 7.3 KB gzip; caps about 8%
above), so they leave the total but keep their own caps and must stay out
of every first load.
- A few one-off utility classes are swapped for ones main already ships.
Result: total JS 1012.7 KB (main +7.3), CSS 21.9 KB (main +0.4), every route
first load within 0.5 KB of main; no cap raised.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2ed5239642
commit
ddc66c6b63
8 files changed
+234
-86
No files matched your search
@@ -36,6 +36,26 @@
|
||||
"$comment": "sql-formatter with the postgresql, mysql, sqlite and sql dialects only (formatDialect), loaded when a workbench cell's Format button is first used: import() in SqlCell. Measured 60.2 KB gzip at introduction; budget about 8% above.",
|
||||
"marker": "logicalOperatorNewline",
|
||||
"gzipKB": 65
|
||||
},
|
||||
"searchPage": {
|
||||
"$comment": "The search results page (/app/search: filters, sort, result cards, split preview pane, side sheet), loaded on demand: import() in app/search/page.tsx. Measured 13.4 KB gzip at introduction (QRun-IO/qqq-frontend-next#59); budget about 8% above.",
|
||||
"marker": "search-result-card",
|
||||
"gzipKB": 14.5
|
||||
},
|
||||
"quickSearchPanel": {
|
||||
"$comment": "The quick search results panel of the header box and the '/' dialog (groups, highlights, completions, failure and fallback notices), loaded when the panel first opens: import() in GlobalSearch and SearchDialog. Measured 9.4 KB gzip at introduction (#59); budget about 8% above.",
|
||||
"marker": "search-results-status",
|
||||
"gzipKB": 10.2
|
||||
},
|
||||
"commandRecordResults": {
|
||||
"$comment": "The command palette's record groups, loaded when the palette searches records: import() in CommandMenu. Measured 7.5 KB gzip at introduction (#59); budget about 8% above.",
|
||||
"marker": "command-menu-searching",
|
||||
"gzipKB": 8.1
|
||||
},
|
||||
"recordPreview": {
|
||||
"$comment": "The search result record preview (first-tier fields, file thumbnails, quick actions), loaded when a preview first shows: import() in SearchPage. It renders values itself rather than through FieldValue, which would pull the widget tree in (+17 KB measured). Measured 7.3 KB gzip at introduction (#59); budget about 8% above.",
|
||||
"marker": "search-record-preview",
|
||||
"gzipKB": 7.9
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,19 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
import { SearchPage } from '@/components/search/SearchPage'
|
||||
/** The results page loads on demand, so its code stays out of every route's first load. */
|
||||
const SearchPage = dynamic(() => import('@/components/search/SearchPage').then((module) => module.SearchPage), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<p className="flex items-center gap-2 p-6 text-sm text-muted-foreground" role="status" data-qqq-id="search-page-loading">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
Loading search…
|
||||
</p>
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* Renders the search page at `/app/search`.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import type { QFieldMetaData, QTableMetaData } from '@/types'
|
||||
import { previewFields } from './RecordPreview'
|
||||
|
||||
/** A field for the fixture. */
|
||||
const field = (name: string, extra: Partial<QFieldMetaData> = {}) => ({ name, label: name, type: 'STRING', adornments: [], ...extra }) as unknown as QFieldMetaData
|
||||
|
||||
describe('previewFields', () => {
|
||||
it('shows the first-tier fields but never the key, heavy fields, secrets, widgets, code, HTML or binary data', () => {
|
||||
const fields = {
|
||||
id: field('id', { type: 'INTEGER' }),
|
||||
name: field('name'),
|
||||
apiKey: field('apiKey', { adornments: [{ type: 'REVEAL' }] } as Partial<QFieldMetaData>),
|
||||
chart: field('chart', { adornments: [{ type: 'WIDGET' }] } as Partial<QFieldMetaData>),
|
||||
script: field('script', { adornments: [{ type: 'CODE_EDITOR' }] } as Partial<QFieldMetaData>),
|
||||
body: field('body', { type: 'HTML' } as Partial<QFieldMetaData>),
|
||||
blob: field('blob', { type: 'BLOB' } as Partial<QFieldMetaData>),
|
||||
notes: field('notes', { isHeavy: true }),
|
||||
status: field('status'),
|
||||
other: field('other'),
|
||||
}
|
||||
const table = {
|
||||
name: 'thing',
|
||||
primaryKeyField: 'id',
|
||||
fields,
|
||||
sections: [{ name: 'identity', tier: 'T1', fieldNames: ['id', 'name', 'apiKey', 'chart', 'script', 'body', 'blob', 'notes', 'status'] }, { name: 'more', tier: 'T2', fieldNames: ['other'] }],
|
||||
} as unknown as QTableMetaData
|
||||
expect(previewFields(table).map((f) => f.name)).toEqual(['name', 'status'])
|
||||
})
|
||||
})
|
||||
@@ -28,14 +28,20 @@ import Link from 'next/link'
|
||||
import { ExternalLink, Link2, Loader2, Pencil, ArrowRight } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { QFieldMetaData, QIcon, QTableMetaData } from '@/types'
|
||||
import type { QFieldMetaData, QIcon, QRecord, QTableMetaData } from '@/types'
|
||||
import type { RecordSearchResult } from '@/lib/api/record-search'
|
||||
import { useRecord } from '@/lib/hooks/use-record'
|
||||
import { useTableMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { canEditRecords } from '@/lib/auth/permissions'
|
||||
import { chipStyle, CHIP_COLOR_CLASSES, findAdornment } from '@/lib/utils/adornment-utils'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { filePreview } from '@/lib/utils/file-preview'
|
||||
import { isEmail, isHttpUrl } from '@/lib/utils/string-utils'
|
||||
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
|
||||
import { recordSearchResultPath } from '@/lib/utils/record-search'
|
||||
import { MetadataIcon } from '@/components/layout/MetadataIcon'
|
||||
import { FieldValue } from '@/components/records/FieldValue'
|
||||
import { DateTimeText } from '@/components/ui/DateTimeText'
|
||||
import { FileThumbnail } from '@/components/preview/FileThumbnail'
|
||||
import { HighlightedText } from './HighlightedText'
|
||||
|
||||
/** Most fields shown in a preview. */
|
||||
@@ -43,7 +49,8 @@ const MAX_PREVIEW_FIELDS = 10
|
||||
|
||||
/**
|
||||
* The fields a preview shows: the visible fields of the table's first-tier sections (as the
|
||||
* record view's T1 cards), else all visible fields; never heavy fields or the primary key.
|
||||
* record view's T1 cards), else all visible fields; never heavy fields, the primary key, secrets,
|
||||
* widgets, code, HTML or binary data.
|
||||
*
|
||||
* @param table - Full table metadata.
|
||||
* @returns Up to {@link MAX_PREVIEW_FIELDS} fields.
|
||||
@@ -54,10 +61,74 @@ export function previewFields(table: QTableMetaData): QFieldMetaData[] {
|
||||
const names = t1.length > 0 ? t1.flatMap((section) => section.fieldNames ?? []) : Object.keys(fields)
|
||||
return [...new Set(names)]
|
||||
.map((name) => fields[name])
|
||||
.filter((field): field is QFieldMetaData => Boolean(field) && !field.isHidden && !field.isHeavy && field.name !== table.primaryKeyField)
|
||||
.filter((field): field is QFieldMetaData => Boolean(field) && !field.isHidden && !field.isHeavy && field.name !== table.primaryKeyField && previewable(field))
|
||||
.slice(0, MAX_PREVIEW_FIELDS)
|
||||
}
|
||||
|
||||
/** Adornments whose fields a glance does not show: secrets, embedded widgets, code and markup. */
|
||||
const NOT_PREVIEWED = ['REVEAL', 'WIDGET', 'CODE_EDITOR', 'RENDER_HTML'] as const
|
||||
|
||||
/**
|
||||
* Whether a preview shows a field: never secrets (REVEAL), widgets, code, HTML or binary data.
|
||||
*
|
||||
* @param field - The field.
|
||||
* @returns True when the preview shows it.
|
||||
*/
|
||||
function previewable(field: QFieldMetaData): boolean {
|
||||
if (field.type === 'BLOB' || field.type === 'HTML') return false
|
||||
return !NOT_PREVIEWED.some((type) => findAdornment(field, type))
|
||||
}
|
||||
|
||||
/** Link classes (taller touch targets on coarse pointers). */
|
||||
const LINK_CLASS = 'text-primary hover:text-primary/80 hover:underline pointer-coarse:inline-flex pointer-coarse:min-h-11 pointer-coarse:items-center'
|
||||
|
||||
/**
|
||||
* A field's value at a glance: file thumbnails, chips, Yes/No, dates, email and web links,
|
||||
* else the display value. Lighter than the record view's FieldValue (no widgets), so the
|
||||
* preview loads quickly.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.field - The field.
|
||||
* @param props.record - The record.
|
||||
* @param props.table - Its table.
|
||||
* @returns The value.
|
||||
*/
|
||||
function PreviewValue({ field, record, table }: { field: QFieldMetaData; record: QRecord; table: QTableMetaData }) {
|
||||
const rawValue = record.values[field.name]
|
||||
const displayValue = record.displayValues?.[field.name]
|
||||
const value = displayValue ?? rawValue
|
||||
const dataQqqId = `search-preview-value-${field.name}`
|
||||
|
||||
const file = filePreview(field, record, storedRecordVariantJson(table))
|
||||
if (file) return <FileThumbnail field={field} record={record} item={file} size={96} showName dataQqqId={dataQqqId} />
|
||||
if (value === null || value === undefined || value === '') return <span className="text-muted-foreground" data-qqq-id={dataQqqId}>—</span>
|
||||
|
||||
if (findAdornment(field, 'CHIP')) {
|
||||
const { color, icon } = chipStyle(field, rawValue)
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold', CHIP_COLOR_CLASSES[color])} data-qqq-id={dataQqqId}>
|
||||
{icon && <MetadataIcon iconName={icon} className="h-3.5 w-3.5" />}
|
||||
{String(value)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (field.type === 'BOOLEAN') {
|
||||
const yes = rawValue === true || rawValue === 'true' || rawValue === 1
|
||||
return <span data-qqq-id={dataQqqId}>{yes ? 'Yes' : 'No'}</span>
|
||||
}
|
||||
if (field.type === 'DATE' || field.type === 'DATE_TIME') {
|
||||
return <DateTimeText type={field.type} value={rawValue} displayValue={displayValue} dataQqqId={dataQqqId} />
|
||||
}
|
||||
const text = String(value)
|
||||
if (field.type === 'STRING' && isHttpUrl(text)) {
|
||||
return <a href={text} target="_blank" rel="noopener noreferrer" className={cn('break-all', LINK_CLASS)} data-qqq-id={dataQqqId}>{text}<span className="sr-only"> (opens in a new tab)</span></a>
|
||||
}
|
||||
if (field.type === 'STRING' && isEmail(text)) {
|
||||
return <a href={`mailto:${text}`} className={cn('break-all', LINK_CLASS)} data-qqq-id={dataQqqId}>{text}</a>
|
||||
}
|
||||
return <span className={cn('whitespace-pre-wrap', field.type === 'TEXT' && 'line-clamp-3')} data-qqq-id={dataQqqId}>{text}</span>
|
||||
}
|
||||
|
||||
/** Props for {@link RecordPreview}. */
|
||||
export interface RecordPreviewProps {
|
||||
/** The search result to preview. */
|
||||
@@ -167,12 +238,12 @@ export function RecordPreview({ result, tableLabel, icon, term, headingId }: Rec
|
||||
Loading details…
|
||||
</p>
|
||||
) : (
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2" data-qqq-id="search-preview-fields">
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2" data-qqq-id="search-preview-fields">
|
||||
{fields.map((field) => (
|
||||
<div key={field.name} className="min-w-0">
|
||||
<dt className="text-xs font-medium text-muted-foreground">{field.label}</dt>
|
||||
<dd className="min-w-0 break-words text-sm text-foreground">
|
||||
<FieldValue field={field} record={record} tableMetaData={table} thumbnailSize={96} />
|
||||
<PreviewValue field={field} record={record} table={table} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -72,7 +72,7 @@ function RelevanceMeter({ value }: { value: number }) {
|
||||
const label = steps === 3 ? 'Strong match' : steps === 2 ? 'Good match' : 'Partial match'
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1" title={`${label} (${Math.round(value * 100)}% of the best result)`} data-qqq-id="search-result-relevance">
|
||||
<span className="flex items-end gap-px" aria-hidden="true">
|
||||
<span className="flex items-end gap-0.5" aria-hidden="true">
|
||||
{[1, 2, 3].map((step) => (
|
||||
<span key={step} className={cn('w-1 rounded-sm', step === 1 ? 'h-1.5' : step === 2 ? 'h-2' : 'h-2.5', step <= steps ? 'bg-primary' : 'bg-muted-foreground/30')} />
|
||||
))}
|
||||
@@ -124,7 +124,7 @@ export function SearchResultCard({ result, tableLabel, icon, term, showTable = f
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn('group relative flex items-start gap-3 px-4 py-3 transition-colors hover:bg-accent/40 sm:px-6', active && 'bg-accent/60 shadow-[inset_3px_0_0_var(--color-primary)]')}
|
||||
className={cn('group relative flex items-start gap-3 px-4 py-3 transition-colors hover:bg-accent/50 sm:px-6', active && 'bg-accent shadow-[inset_3px_0_0_var(--color-primary)]')}
|
||||
data-qqq-id="search-result-card"
|
||||
data-active={active || undefined}
|
||||
onMouseEnter={onActivate}
|
||||
@@ -179,7 +179,7 @@ export function SearchResultCard({ result, tableLabel, icon, term, showTable = f
|
||||
</dl>
|
||||
)}
|
||||
{(details.length > 0 || result.updatedAt) && (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
{details.map((detail) => (
|
||||
<span
|
||||
key={detail.fieldName}
|
||||
|
||||
@@ -23,8 +23,81 @@
|
||||
* fallback matches). Every extension field is optional.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
import apiClient from './client'
|
||||
import { RecordSearchResponseSchema, RecordSearchResultSchema } from './schemas'
|
||||
|
||||
// The response schemas live here, not in ./schemas: only record search uses them, and
|
||||
// keeping them out of the shared module keeps them out of every screen's chunks.
|
||||
|
||||
/** A `[start, end)` character range of a match inside a highlight's text. */
|
||||
const RecordSearchMatchSchema = z.object({ start: z.number().int().nonnegative(), end: z.number().int().nonnegative() })
|
||||
|
||||
/**
|
||||
* One highlighted field of a record search result: plain text (never HTML) plus the
|
||||
* ranges that matched the search term.
|
||||
*/
|
||||
export const RecordSearchHighlightSchema = z.object({
|
||||
fieldName: z.string(),
|
||||
fieldLabel: z.string().nullish(),
|
||||
text: z.string(),
|
||||
matches: z.array(RecordSearchMatchSchema).nullish(),
|
||||
})
|
||||
|
||||
/** A secondary (non-matching) field value shown under a result's label. */
|
||||
export const RecordSearchDetailSchema = z.object({
|
||||
fieldName: z.string(),
|
||||
fieldLabel: z.string().nullish(),
|
||||
displayValue: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Zod schema for one record search result. `tableName` and `recordId` are required (a
|
||||
* result cannot link anywhere without them); `recordId` may arrive as a number. Labels may
|
||||
* be missing, null or empty: QQQ leaves empty values out of its JSON, so a record whose
|
||||
* label fields are empty arrives without `recordLabel`, and callers label it themselves.
|
||||
* The extension fields (score, highlights, details, updatedAt) are optional, and a
|
||||
* malformed one is dropped (`catch`) instead of failing the result.
|
||||
*/
|
||||
export const RecordSearchResultSchema = z.object({
|
||||
tableName: z.string().min(1),
|
||||
tableLabel: z.string().nullish().transform((value) => value || undefined),
|
||||
recordId: z.union([z.string().min(1), z.number()]).transform(String),
|
||||
recordLabel: z.string().nullish().transform((value) => value || undefined),
|
||||
score: z.number().nullish().catch(undefined),
|
||||
updatedAt: z.string().nullish().catch(undefined),
|
||||
highlights: z.array(RecordSearchHighlightSchema).nullish().catch(undefined),
|
||||
details: z.array(RecordSearchDetailSchema).nullish().catch(undefined),
|
||||
})
|
||||
|
||||
/** Per-table summary of a record search (newer backends). */
|
||||
export const RecordSearchTableSchema = z.object({
|
||||
tableName: z.string(),
|
||||
tableLabel: z.string().nullish(),
|
||||
resultCount: z.number().nullish(),
|
||||
hasMore: z.boolean().nullish(),
|
||||
totalCount: z.number().nullish(),
|
||||
source: z.string().nullish(),
|
||||
})
|
||||
|
||||
/** Status of the instance's record search provider (e.g. a search index), newer backends. */
|
||||
export const RecordSearchProviderStatusSchema = z.object({
|
||||
status: z.string(),
|
||||
failureClass: z.string().nullish(),
|
||||
message: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Zod schema for the record search response: `{ results: [...] }`, plus the optional
|
||||
* per-table summaries and provider status of newer backends. Each result is validated
|
||||
* separately ({@link RecordSearchResultSchema}), so one malformed result is skipped
|
||||
* rather than failing the whole search.
|
||||
*/
|
||||
export const RecordSearchResponseSchema = z.object({
|
||||
results: z.array(z.unknown()),
|
||||
tables: z.array(RecordSearchTableSchema).nullish().catch(undefined),
|
||||
provider: RecordSearchProviderStatusSchema.nullish().catch(undefined),
|
||||
})
|
||||
|
||||
/** A `[start, end)` character range of a match inside a highlight's text. */
|
||||
export interface RecordSearchMatch {
|
||||
|
||||
@@ -27,10 +27,9 @@ import {
|
||||
QRecordSchema,
|
||||
QueryRecordsResponseSchema,
|
||||
CountRecordsResponseSchema,
|
||||
RecordSearchResultSchema,
|
||||
RecordSearchResponseSchema,
|
||||
QInstanceMinimalSchema,
|
||||
} from './schemas'
|
||||
import { RecordSearchResultSchema, RecordSearchResponseSchema } from './record-search'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QRecordSchema
|
||||
|
||||
@@ -93,79 +93,6 @@ export const CountRecordsResponseSchema = z.object({
|
||||
distinctCount: z.number().optional(),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Record search — POST /search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A `[start, end)` character range of a match inside a highlight's text. */
|
||||
const RecordSearchMatchSchema = z.object({ start: z.number().int().nonnegative(), end: z.number().int().nonnegative() })
|
||||
|
||||
/**
|
||||
* One highlighted field of a record search result: plain text (never HTML) plus the
|
||||
* ranges that matched the search term.
|
||||
*/
|
||||
export const RecordSearchHighlightSchema = z.object({
|
||||
fieldName: z.string(),
|
||||
fieldLabel: z.string().nullish(),
|
||||
text: z.string(),
|
||||
matches: z.array(RecordSearchMatchSchema).nullish(),
|
||||
})
|
||||
|
||||
/** A secondary (non-matching) field value shown under a result's label. */
|
||||
export const RecordSearchDetailSchema = z.object({
|
||||
fieldName: z.string(),
|
||||
fieldLabel: z.string().nullish(),
|
||||
displayValue: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Zod schema for one record search result. `tableName` and `recordId` are required (a
|
||||
* result cannot link anywhere without them); `recordId` may arrive as a number. Labels may
|
||||
* be missing, null or empty: QQQ leaves empty values out of its JSON, so a record whose
|
||||
* label fields are empty arrives without `recordLabel`, and callers label it themselves.
|
||||
* The extension fields (score, highlights, details, updatedAt) are optional, and a
|
||||
* malformed one is dropped (`catch`) instead of failing the result.
|
||||
*/
|
||||
export const RecordSearchResultSchema = z.object({
|
||||
tableName: z.string().min(1),
|
||||
tableLabel: z.string().nullish().transform((value) => value || undefined),
|
||||
recordId: z.union([z.string().min(1), z.number()]).transform(String),
|
||||
recordLabel: z.string().nullish().transform((value) => value || undefined),
|
||||
score: z.number().nullish().catch(undefined),
|
||||
updatedAt: z.string().nullish().catch(undefined),
|
||||
highlights: z.array(RecordSearchHighlightSchema).nullish().catch(undefined),
|
||||
details: z.array(RecordSearchDetailSchema).nullish().catch(undefined),
|
||||
})
|
||||
|
||||
/** Per-table summary of a record search (newer backends). */
|
||||
export const RecordSearchTableSchema = z.object({
|
||||
tableName: z.string(),
|
||||
tableLabel: z.string().nullish(),
|
||||
resultCount: z.number().nullish(),
|
||||
hasMore: z.boolean().nullish(),
|
||||
totalCount: z.number().nullish(),
|
||||
source: z.string().nullish(),
|
||||
})
|
||||
|
||||
/** Status of the instance's record search provider (e.g. a search index), newer backends. */
|
||||
export const RecordSearchProviderStatusSchema = z.object({
|
||||
status: z.string(),
|
||||
failureClass: z.string().nullish(),
|
||||
message: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Zod schema for the record search response: `{ results: [...] }`, plus the optional
|
||||
* per-table summaries and provider status of newer backends. Each result is validated
|
||||
* separately ({@link RecordSearchResultSchema}), so one malformed result is skipped
|
||||
* rather than failing the whole search.
|
||||
*/
|
||||
export const RecordSearchResponseSchema = z.object({
|
||||
results: z.array(z.unknown()),
|
||||
tables: z.array(RecordSearchTableSchema).nullish().catch(undefined),
|
||||
provider: RecordSearchProviderStatusSchema.nullish().catch(undefined),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QInstance — GET /metaData (minimal required-field validation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user