@voyant-travel/catalog
v0.230.0
Published
Catalog plane foundation for Voyant. The shared cross-cutting infrastructure that Inventory, vertical modules, resale modules, and booking add-on surfaces adopt to participate in a normalized discovery / overlay / snapshot / search surface.
Readme
@voyant-travel/catalog
Catalog plane foundation for Voyant. The shared cross-cutting infrastructure that Inventory, vertical modules, resale modules, and booking add-on surfaces adopt to participate in a normalized discovery / overlay / snapshot / search surface.
This package owns the catalog plane foundation plus semantic search primitives: embedding providers, model compatibility helpers, hybrid/semantic search, and cross-audience federation. Agent runtimes wrap the catalog HTTP APIs directly; MCP packaging is application-owned.
See docs/architecture/catalog-architecture.md for the full design.
Install
pnpm add @voyant-travel/catalogInstall @voyant-travel/catalog-contracts instead when you only need the pure
adapter payload types, adapter Zod schemas, field-policy contracts,
provenance, drift payloads, or content locale/overlay helpers. Use this package
when you also need Drizzle schema, Hono routes, booking-engine integration,
search services, or catalog runtime services.
What's in the box
./contract—FieldPolicytype and the eleven governance enums. The load-bearing schema decision: every field on every Catalog Item projection is declared with a row in a per-vertical policy file../provenance—Provenanceshape (source_kind,source_ref,source_freshness) carried by every Catalog Item projection../overlay/schema— drizzle table schema for editorial overrides keyed(entity_module, entity_id, field_path, locale, audience, market)../overlay/resolver— resolver-merge logic with full locale × audience × market fallback chain../snapshot/schema—booking_catalog_snapshottable for immutable booking-time Catalog Item projection views../indexer/contract— compatibility re-export of the engine-agnostic contracts now owned by@voyant-travel/catalog-contracts/indexer/contract../indexer/provider— thecatalog.indexerruntime port used by deployment composition../indexer/postgres— native PostgresIndexerAdapter, the first-party managed-cloud default. It keeps a rebuildable catalog projection in the deployment database and uses the deployment-owned resident pool. Set the recordedPOSTGRES_SEARCH_TEXT_STRATEGY=lakebaseand/orPOSTGRES_SEARCH_VECTOR_STRATEGY=lakebaseonly when Lakebase Search has provisionedlakebase_textand/orlakebase_vector; usePOSTGRES_SEARCH_VECTOR_STRATEGY=pgvectorwith a deployment vector dimension when only thevectorextension is provisioned. Native FTS remains the portable lexical fallback, andPOSTGRES_SEARCH_TYPO_STRATEGY=pgtrgmenables curated-term typo recovery whenpg_trgmis provisioned. Its privateprojectionGeneration(slice)token changes after successful writes and is intended for deployment-level cache keys, not public responses. On transaction-capable deployments, each search reads candidates and facets from a repeatable-read, read-only projection snapshot. Its signed cursors include every searched projection generation and reject continuation after a write or rebuild, preventing mixed-generation pagination. Policy-backed scalar filters are maintained in typed facet rows before search candidate generation. A successful full rebuild retains one predecessor per slice; deployment maintenance may use the privaterollbackProjection(slice)operation before steady writes invalidate that rollback snapshot. Interrupted bulk streams retain their staged chunks for a retry when callers reuse the samerebuildRunIdfor one source snapshot; a changed source uses a new run id and cannot publish stale staging rows.projectionState(slice)reports the pending staged-document count until atomic publication succeeds../indexer/typesense— native TypesenseIndexerAdapter, retained as a selectable first-party provider../indexer/postgres-provider— graph provider factory selected bydeployment.providers.search: "postgres"../indexer/relevance— shared travel relevance corpus and comparison harness for measuring Postgres against a selected baseline adapter../indexer/typesense-provider— graph provider factory selected bydeployment.providers.search: "typesense"../search/rerank— Tier 2 two-stage-search orchestration helper for browse-time pricing../drift/events— drift event types for upstream change detection../events/taxonomy— catalog event names + visibility-filtered payload builders, emitted via@voyant-travel/core/eventsand consumed by the existing webhook pipeline../adapter/contract— public source-adapter contract. Voyant Connect, third-party providers, operator-built adapters all implement this../adapter/schemas— zod schemas for source-adapter runtime payloads. Use these at HTTP, queue, RPC, and adapter boundaries instead of re-declaring validators../booking-engine— quote/book services plus the Hono route module that backs@voyant-travel/catalog-react/booking-engineand@voyant-travel/bookings-react/journey.
Architectural rules
The catalog plane is a contract, not a polymorphic root. Vertical modules keep their own schemas and adopt this contract; they do not share a row shape. See the architecture doc for the full rationale.
- Per-vertical operational truth — separate tables per vertical.
- Shared cross-cutting infrastructure — overlay store, snapshot graph, indexer pipeline, drift events, webhooks.
- Three composition patterns — nested fields, promoted child entities, referenced CatalogEntries.
- Three variant axes on overlays —
locale,audience,market; sparse, default deployment uses two audiences and one market.
Search index providers
Search-engine choice is deployment configuration, not environment detection.
Set deployment.providers.search in voyant.config.ts; credentials configure
the selected provider but never select it:
import { defineConfig } from "@voyant-travel/framework/project"
export default defineConfig({
deployment: {
target: "node",
providers: { search: "postgres" },
},
})postgres selects the first-party provider declared by this package. It uses
the same graph-declared Postgres resource as the application and never opens a
separate database pool from an environment variable. typesense remains
available and uses TYPESENSE_HOST plus TYPESENSE_API_KEY. The standard
self-hosted operator configuration uses search: "none" until search is
enabled.
Embedded hosts and tests can supply a custom implementation by selecting
deployment.providers.search: "custom" and passing either an IndexerAdapter
or IndexerProvider at the catalog.indexer runtime port. The direct adapter
form is useful when the host already owns the configured adapter instance:
// voyant.config.ts
import { defineConfig } from "@voyant-travel/framework/project"
export default defineConfig({
deployment: {
target: "node",
providers: { search: "custom" },
},
})import { catalogIndexerProviderPort } from "@voyant-travel/catalog/indexer/provider"
import type { IndexerAdapter } from "@voyant-travel/catalog-contracts/indexer/contract"
import { loadVoyantProject } from "@voyant-travel/runtime"
const indexer: IndexerAdapter = createCustomIndexer()
await loadVoyantProject({
host: {
runtimePorts: { [catalogIndexerProviderPort.id]: indexer },
},
})The explicit port is ignored for none, typesense, algolia, and every
other non-custom search selection. Those values remain authoritative and
resolve only their selected graph provider. With custom, an explicit host
port takes precedence over a graph-declared custom provider; without an
explicit port, the graph-declared custom provider resolves normally.
Algolia and other engines are external adapter packages. They implement
IndexerAdapter and IndexerProvider from
@voyant-travel/catalog-contracts/indexer/contract, publish a graph provider
for port catalog.indexer, and declare a matching selection such as
{ role: "search", value: "algolia" } or
{ role: "search", value: "custom" }. The deployment admits that package and
sets the corresponding deployment.providers.search value; no Algolia SDK or
vendor code belongs in @voyant-travel/catalog.
Adapter packages should run the test-framework-neutral conformance kit from
@voyant-travel/catalog-contracts/indexer/conformance in their own test suite:
import { assertIndexerAdapterConformance } from "@voyant-travel/catalog-contracts/indexer/conformance"
import type { IndexerProvider } from "@voyant-travel/catalog-contracts/indexer/contract"
const indexerProvider: IndexerProvider = {
create: (options) => createCustomIndexer(options),
}
await assertIndexerAdapterConformance({
createAdapter: () => indexerProvider.create({ registries: new Map() }),
})Provider-neutral maintenance uses the optional IndexerAdapter.admin surface
(list, drop, and scan). Raw Typesense collection/search maintenance APIs
are not public catalog package surface.
Relevance comparison
@voyant-travel/catalog/indexer/relevance provides a curated travel corpus and
an adapter-level harness for comparing Postgres with Typesense (or another
approved baseline). It reports recall@k, NDCG@k, zero-result rate, and exact
facet-bucket parity. Provider scores are intentionally excluded from the
comparison because each engine normalizes relevance differently. Run it against
the real deployment adapters and retain the resulting report with the rollout
evidence; the built-in corpus is a minimum regression gate, not a replacement
for operator-approved travel judgments.
Usage
The catalog plane is consumed by vertical modules; templates wire it together.
import { defineFieldPolicy } from "@voyant-travel/catalog/contract"
export const productCatalogPolicy = defineFieldPolicy([
{
path: "title",
class: "merchandisable",
merge: "replace",
drift: "medium",
reindex: "entry-locale",
snapshot: "on-book",
query: "indexed-column",
localized: true,
visibility: ["staff", "customer", "partner"],
editRole: "marketing",
overrideFriction: "none",
sourceFreshness: "sync",
},
// ...
])See docs/architecture/catalog-architecture.md for the full contract and worked examples.
Source-adapter runtime validation
import { reserveRequestSchema } from "@voyant-travel/catalog/adapter/schemas"
import type { ReserveRequest } from "@voyant-travel/catalog/adapter/contract"
const request: ReserveRequest = reserveRequestSchema.parse(await req.json())Reserve and cancel requests may include a scope matching live resolution plus
an idempotency_key; cancel results may return status: "pending" with
pending_channel for async upstream workflows.
External adapters that do not run the catalog package can import the same
schemas and types from @voyant-travel/catalog-contracts/adapter/schemas and
@voyant-travel/catalog-contracts/adapter/contract.
Catalog quote and draft HTTP routes
@voyant-travel/catalog exports createCatalogBookingApiModule(...) and
createCatalogBookingRoutes(...) for catalog quote, draft, hold, and reservation
contracts. The same functions remain available from
@voyant-travel/catalog/booking-engine for consumers that prefer the narrower
subpath. The module mounts these engine endpoints on both catalog API surfaces.
They are not a second booking-row creation authority. Booking Session Commit is
the authenticated staff and storefront creation authority; it derives and
executes Finance's durable command only after the exact Quote and Hold have
been validated:
/v1/admin/catalog/*/v1/public/catalog/*
Templates provide the runtime dependencies instead of the package importing deployment code:
import { createCatalogBookingApiModule } from "@voyant-travel/catalog"
export const catalogBookingModule = createCatalogBookingApiModule({
resolveDb: (c) => c.get("db"),
resolveSourceRegistry: (c) => getBookingEngineRegistryFromContext(c),
resolveOwnedHandlers: (c) => getOwnedBookingHandlerRegistryFromContext(c),
})Apps that protect public routes by default must allow
/v1/public/catalog. Template-specific routes such as slots, admin order
management, checkout start, and booking snapshot enrichment stay in the
template.
Catalog search HTTP routes
@voyant-travel/catalog also exports createCatalogSearchApiModule(...),
createCatalogSearchRoutes(...), and mountCatalogSearchRoutes(...) for the
plain JSON catalog search endpoint used by admin and storefront UIs:
POST /v1/admin/catalog/searchPOST /v1/public/catalog/search
The module owns audience defaults: admin search uses the runtime
defaultScope.audience, while public search defaults to the customer
projection. Deployments provide the indexer and optional semantic executor per
request:
import {
createCatalogSearchApiModule,
executeSemanticSearch,
type EmbeddingProvider,
} from "@voyant-travel/catalog"
export const catalogSearchModule = createCatalogSearchApiModule({
resolveRuntime: (c) => buildCatalogSearchRuntime(c),
executeSearch: ({ adapter, embeddings, slice, request }) =>
executeSemanticSearch({
adapter,
embeddings: embeddings as EmbeddingProvider | undefined,
slice,
request,
}),
})Search defaults to hybrid mode, downgrades to keyword when no embeddings are
available, and retries semantic/hybrid execution as keyword when the semantic
path fails. Pass fallbackToKeywordOnSearchError: false to fail closed instead.
Storefront listing pages can request typed index-layer sorting and a compact card projection from the public route:
await fetch("/v1/public/catalog/search", {
method: "POST",
body: JSON.stringify({
vertical: "products",
query: "",
mode: "keyword",
sort: "price-asc",
projection: "storefront-card",
pagination: { limit: 12 },
facets: [{ field: "categorySlugs[]" }, { field: "departureMonths[]" }],
}),
})Supported sort values are relevance, price-asc, price-desc,
departure-asc, and newest. Sorts are translated by the indexer adapter to
safe indexed fields such as priceFromAmountCents and nextDepartureDate; they
are not applied after app-side hydration.
When projection: "storefront-card" is present, the response keeps the raw
hits, total, and engine facet counts, and also includes cards with the
fields storefront product grids commonly need: localized name/slug, primary
category, media URLs, price-from and offer badge data, departure aggregates,
destinations, and coordinates.
