@voyant-travel/catalog 0.117.2
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.
- package/LICENSE +201 -0
- package/README.md +190 -0
- package/dist/adapter/booking-forwarding.d.ts +2 -0
- package/dist/adapter/booking-forwarding.d.ts.map +1 -0
- package/dist/adapter/booking-forwarding.js +1 -0
- package/dist/adapter/channel-push-contracts.d.ts +2 -0
- package/dist/adapter/channel-push-contracts.d.ts.map +1 -0
- package/dist/adapter/channel-push-contracts.js +1 -0
- package/dist/adapter/contract.d.ts +2 -0
- package/dist/adapter/contract.d.ts.map +1 -0
- package/dist/adapter/contract.js +1 -0
- package/dist/adapter/contract.test.d.ts +2 -0
- package/dist/adapter/contract.test.d.ts.map +1 -0
- package/dist/adapter/contract.test.js +390 -0
- package/dist/adapter/provider-contracts.d.ts +2 -0
- package/dist/adapter/provider-contracts.d.ts.map +1 -0
- package/dist/adapter/provider-contracts.js +1 -0
- package/dist/adapter/provider-contracts.test.d.ts +2 -0
- package/dist/adapter/provider-contracts.test.d.ts.map +1 -0
- package/dist/adapter/provider-contracts.test.js +206 -0
- package/dist/adapter/schemas.d.ts +2 -0
- package/dist/adapter/schemas.d.ts.map +1 -0
- package/dist/adapter/schemas.js +1 -0
- package/dist/adapter/schemas.test.d.ts +2 -0
- package/dist/adapter/schemas.test.d.ts.map +1 -0
- package/dist/adapter/schemas.test.js +344 -0
- package/dist/booking-engine/book.d.ts +124 -0
- package/dist/booking-engine/book.d.ts.map +1 -0
- package/dist/booking-engine/book.js +311 -0
- package/dist/booking-engine/cancel.d.ts +40 -0
- package/dist/booking-engine/cancel.d.ts.map +1 -0
- package/dist/booking-engine/cancel.js +56 -0
- package/dist/booking-engine/checkout-finalize.d.ts +146 -0
- package/dist/booking-engine/checkout-finalize.d.ts.map +1 -0
- package/dist/booking-engine/checkout-finalize.js +132 -0
- package/dist/booking-engine/contracts.d.ts +9 -0
- package/dist/booking-engine/contracts.d.ts.map +1 -0
- package/dist/booking-engine/contracts.js +8 -0
- package/dist/booking-engine/contracts.test.d.ts +2 -0
- package/dist/booking-engine/contracts.test.d.ts.map +1 -0
- package/dist/booking-engine/contracts.test.js +116 -0
- package/dist/booking-engine/draft-shape.d.ts +10 -0
- package/dist/booking-engine/draft-shape.d.ts.map +1 -0
- package/dist/booking-engine/draft-shape.js +9 -0
- package/dist/booking-engine/draft-shape.test.d.ts +2 -0
- package/dist/booking-engine/draft-shape.test.d.ts.map +1 -0
- package/dist/booking-engine/draft-shape.test.js +74 -0
- package/dist/booking-engine/drafts-schema.d.ts +302 -0
- package/dist/booking-engine/drafts-schema.d.ts.map +1 -0
- package/dist/booking-engine/drafts-schema.js +53 -0
- package/dist/booking-engine/drafts-service.d.ts +41 -0
- package/dist/booking-engine/drafts-service.d.ts.map +1 -0
- package/dist/booking-engine/drafts-service.js +108 -0
- package/dist/booking-engine/errors.d.ts +81 -0
- package/dist/booking-engine/errors.d.ts.map +1 -0
- package/dist/booking-engine/errors.js +113 -0
- package/dist/booking-engine/index.d.ts +36 -0
- package/dist/booking-engine/index.d.ts.map +1 -0
- package/dist/booking-engine/index.js +34 -0
- package/dist/booking-engine/orders.d.ts +41 -0
- package/dist/booking-engine/orders.d.ts.map +1 -0
- package/dist/booking-engine/orders.js +49 -0
- package/dist/booking-engine/owned-handler.d.ts +166 -0
- package/dist/booking-engine/owned-handler.d.ts.map +1 -0
- package/dist/booking-engine/owned-handler.js +50 -0
- package/dist/booking-engine/owned-handler.test.d.ts +2 -0
- package/dist/booking-engine/owned-handler.test.d.ts.map +1 -0
- package/dist/booking-engine/owned-handler.test.js +63 -0
- package/dist/booking-engine/promotions-contract.d.ts +8 -0
- package/dist/booking-engine/promotions-contract.d.ts.map +1 -0
- package/dist/booking-engine/promotions-contract.js +7 -0
- package/dist/booking-engine/quote-enricher.test.d.ts +12 -0
- package/dist/booking-engine/quote-enricher.test.d.ts.map +1 -0
- package/dist/booking-engine/quote-enricher.test.js +138 -0
- package/dist/booking-engine/quote.d.ts +163 -0
- package/dist/booking-engine/quote.d.ts.map +1 -0
- package/dist/booking-engine/quote.js +259 -0
- package/dist/booking-engine/registry.d.ts +85 -0
- package/dist/booking-engine/registry.d.ts.map +1 -0
- package/dist/booking-engine/registry.js +118 -0
- package/dist/booking-engine/registry.test.d.ts +2 -0
- package/dist/booking-engine/registry.test.d.ts.map +1 -0
- package/dist/booking-engine/registry.test.js +132 -0
- package/dist/booking-engine/routes-contracts.d.ts +169 -0
- package/dist/booking-engine/routes-contracts.d.ts.map +1 -0
- package/dist/booking-engine/routes-contracts.js +63 -0
- package/dist/booking-engine/routes.d.ts +7 -0
- package/dist/booking-engine/routes.d.ts.map +1 -0
- package/dist/booking-engine/routes.js +443 -0
- package/dist/booking-engine/routes.test.d.ts +2 -0
- package/dist/booking-engine/routes.test.d.ts.map +1 -0
- package/dist/booking-engine/routes.test.js +304 -0
- package/dist/booking-engine/schema.d.ts +455 -0
- package/dist/booking-engine/schema.d.ts.map +1 -0
- package/dist/booking-engine/schema.js +75 -0
- package/dist/booking-engine/snapshot-content.d.ts +120 -0
- package/dist/booking-engine/snapshot-content.d.ts.map +1 -0
- package/dist/booking-engine/snapshot-content.js +110 -0
- package/dist/booking-engine/snapshot-content.test.d.ts +2 -0
- package/dist/booking-engine/snapshot-content.test.d.ts.map +1 -0
- package/dist/booking-engine/snapshot-content.test.js +213 -0
- package/dist/booking-engine/sync.d.ts +136 -0
- package/dist/booking-engine/sync.d.ts.map +1 -0
- package/dist/booking-engine/sync.js +177 -0
- package/dist/booking-engine/sync.test.d.ts +2 -0
- package/dist/booking-engine/sync.test.d.ts.map +1 -0
- package/dist/booking-engine/sync.test.js +377 -0
- package/dist/contract.d.ts +2 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +1 -0
- package/dist/contract.test.d.ts +2 -0
- package/dist/contract.test.d.ts.map +1 -0
- package/dist/contract.test.js +107 -0
- package/dist/drift/events.d.ts +2 -0
- package/dist/drift/events.d.ts.map +1 -0
- package/dist/drift/events.js +1 -0
- package/dist/drift/events.test.d.ts +2 -0
- package/dist/drift/events.test.d.ts.map +1 -0
- package/dist/drift/events.test.js +100 -0
- package/dist/embeddings/contract.d.ts +85 -0
- package/dist/embeddings/contract.d.ts.map +1 -0
- package/dist/embeddings/contract.js +42 -0
- package/dist/embeddings/contract.test.d.ts +2 -0
- package/dist/embeddings/contract.test.d.ts.map +1 -0
- package/dist/embeddings/contract.test.js +30 -0
- package/dist/embeddings/gemini.d.ts +110 -0
- package/dist/embeddings/gemini.d.ts.map +1 -0
- package/dist/embeddings/gemini.js +118 -0
- package/dist/embeddings/gemini.test.d.ts +2 -0
- package/dist/embeddings/gemini.test.d.ts.map +1 -0
- package/dist/embeddings/gemini.test.js +132 -0
- package/dist/embeddings/model-registry.d.ts +62 -0
- package/dist/embeddings/model-registry.d.ts.map +1 -0
- package/dist/embeddings/model-registry.js +78 -0
- package/dist/embeddings/model-registry.test.d.ts +2 -0
- package/dist/embeddings/model-registry.test.d.ts.map +1 -0
- package/dist/embeddings/model-registry.test.js +81 -0
- package/dist/embeddings/openai.d.ts +81 -0
- package/dist/embeddings/openai.d.ts.map +1 -0
- package/dist/embeddings/openai.js +123 -0
- package/dist/embeddings/openai.test.d.ts +2 -0
- package/dist/embeddings/openai.test.d.ts.map +1 -0
- package/dist/embeddings/openai.test.js +164 -0
- package/dist/events/taxonomy.d.ts +158 -0
- package/dist/events/taxonomy.d.ts.map +1 -0
- package/dist/events/taxonomy.js +99 -0
- package/dist/events/taxonomy.test.d.ts +2 -0
- package/dist/events/taxonomy.test.d.ts.map +1 -0
- package/dist/events/taxonomy.test.js +48 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/indexer/contract.d.ts +203 -0
- package/dist/indexer/contract.d.ts.map +1 -0
- package/dist/indexer/contract.js +16 -0
- package/dist/indexer/typesense-search-query.d.ts +31 -0
- package/dist/indexer/typesense-search-query.d.ts.map +1 -0
- package/dist/indexer/typesense-search-query.js +185 -0
- package/dist/indexer/typesense.d.ts +105 -0
- package/dist/indexer/typesense.d.ts.map +1 -0
- package/dist/indexer/typesense.js +394 -0
- package/dist/indexer/typesense.test.d.ts +2 -0
- package/dist/indexer/typesense.test.d.ts.map +1 -0
- package/dist/indexer/typesense.test.js +253 -0
- package/dist/overlay/resolver.d.ts +101 -0
- package/dist/overlay/resolver.d.ts.map +1 -0
- package/dist/overlay/resolver.js +167 -0
- package/dist/overlay/resolver.test.d.ts +2 -0
- package/dist/overlay/resolver.test.d.ts.map +1 -0
- package/dist/overlay/resolver.test.js +179 -0
- package/dist/overlay/schema.d.ts +266 -0
- package/dist/overlay/schema.d.ts.map +1 -0
- package/dist/overlay/schema.js +71 -0
- package/dist/provenance.d.ts +2 -0
- package/dist/provenance.d.ts.map +1 -0
- package/dist/provenance.js +1 -0
- package/dist/schema-sourced-entries.d.ts +344 -0
- package/dist/schema-sourced-entries.d.ts.map +1 -0
- package/dist/schema-sourced-entries.js +75 -0
- package/dist/schema.d.ts +21 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +20 -0
- package/dist/search/federate.d.ts +58 -0
- package/dist/search/federate.d.ts.map +1 -0
- package/dist/search/federate.js +103 -0
- package/dist/search/federate.test.d.ts +2 -0
- package/dist/search/federate.test.d.ts.map +1 -0
- package/dist/search/federate.test.js +146 -0
- package/dist/search/rerank.d.ts +77 -0
- package/dist/search/rerank.d.ts.map +1 -0
- package/dist/search/rerank.js +68 -0
- package/dist/search/rerank.test.d.ts +2 -0
- package/dist/search/rerank.test.d.ts.map +1 -0
- package/dist/search/rerank.test.js +60 -0
- package/dist/search/routes.d.ts +144 -0
- package/dist/search/routes.d.ts.map +1 -0
- package/dist/search/routes.js +288 -0
- package/dist/search/routes.test.d.ts +2 -0
- package/dist/search/routes.test.d.ts.map +1 -0
- package/dist/search/routes.test.js +322 -0
- package/dist/search/semantic.d.ts +63 -0
- package/dist/search/semantic.d.ts.map +1 -0
- package/dist/search/semantic.js +75 -0
- package/dist/search/semantic.test.d.ts +2 -0
- package/dist/search/semantic.test.d.ts.map +1 -0
- package/dist/search/semantic.test.js +143 -0
- package/dist/services/build-indexer-document.test.d.ts +2 -0
- package/dist/services/build-indexer-document.test.d.ts.map +1 -0
- package/dist/services/build-indexer-document.test.js +102 -0
- package/dist/services/content-service.d.ts +125 -0
- package/dist/services/content-service.d.ts.map +1 -0
- package/dist/services/content-service.js +139 -0
- package/dist/services/content-service.test.d.ts +2 -0
- package/dist/services/content-service.test.d.ts.map +1 -0
- package/dist/services/content-service.test.js +322 -0
- package/dist/services/indexer-service.d.ts +109 -0
- package/dist/services/indexer-service.d.ts.map +1 -0
- package/dist/services/indexer-service.js +123 -0
- package/dist/services/indexer-service.test.d.ts +2 -0
- package/dist/services/indexer-service.test.d.ts.map +1 -0
- package/dist/services/indexer-service.test.js +176 -0
- package/dist/services/overlay-service.d.ts +108 -0
- package/dist/services/overlay-service.d.ts.map +1 -0
- package/dist/services/overlay-service.js +211 -0
- package/dist/services/overlay-service.test.d.ts +2 -0
- package/dist/services/overlay-service.test.d.ts.map +1 -0
- package/dist/services/overlay-service.test.js +79 -0
- package/dist/services/snapshot-builder.test.d.ts +2 -0
- package/dist/services/snapshot-builder.test.d.ts.map +1 -0
- package/dist/services/snapshot-builder.test.js +93 -0
- package/dist/services/snapshot-service.d.ts +78 -0
- package/dist/services/snapshot-service.d.ts.map +1 -0
- package/dist/services/snapshot-service.js +165 -0
- package/dist/services/sourced-entry-service.d.ts +142 -0
- package/dist/services/sourced-entry-service.d.ts.map +1 -0
- package/dist/services/sourced-entry-service.js +203 -0
- package/dist/services/sourced-entry-service.test.d.ts +10 -0
- package/dist/services/sourced-entry-service.test.d.ts.map +1 -0
- package/dist/services/sourced-entry-service.test.js +66 -0
- package/dist/snapshot/schema.d.ts +362 -0
- package/dist/snapshot/schema.d.ts.map +1 -0
- package/dist/snapshot/schema.js +102 -0
- package/package.json +210 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema-sourced-entries.d.ts","sourceRoot":"","sources":["../src/schema-sourced-entries.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAKH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAGpD;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,GAAG,QAAQ,GAAG,WAAW,GAAG,UAAU,CAAA;AAEpE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA2DtC,CAAA;AAED,MAAM,MAAM,yBAAyB,GAAG,OAAO,0BAA0B,CAAC,YAAY,CAAA;AACtF,MAAM,MAAM,yBAAyB,GAAG,OAAO,0BAA0B,CAAC,YAAY,CAAA"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sourced-entry store — durable provenance + projection capture for every
|
|
3
|
+
* non-owned entity that lands in the catalog plane via `discover()`.
|
|
4
|
+
*
|
|
5
|
+
* This is the load-bearing prerequisite for the content cache, the read
|
|
6
|
+
* service, drift invalidation, and snapshot capture. All of them assume
|
|
7
|
+
* there's a durable local row per sourced entity that records *what we
|
|
8
|
+
* know about it locally* — provenance (which adapter, which connection,
|
|
9
|
+
* which upstream id), lifecycle (when first seen, when last seen, whether
|
|
10
|
+
* still active), and the canonical local copy of the indexed projection.
|
|
11
|
+
*
|
|
12
|
+
* Owned entities do NOT have a row here — `provenance.source_kind ===
|
|
13
|
+
* "owned"` reads provenance from the vertical's owned schema. The
|
|
14
|
+
* sourced-entry store is sourced-only.
|
|
15
|
+
*
|
|
16
|
+
* See `docs/architecture/catalog-sourced-content.md` §2.5 for the full
|
|
17
|
+
* design.
|
|
18
|
+
*/
|
|
19
|
+
import { typeId } from "@voyant-travel/db/lib/typeid-column";
|
|
20
|
+
import { index, jsonb, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
|
|
21
|
+
/**
|
|
22
|
+
* `catalog_sourced_entries` — single durable record per sourced entity,
|
|
23
|
+
* keyed two ways: by Voyant-side `(entity_module, entity_id)` for
|
|
24
|
+
* read-path lookup, and by upstream-side `(source_kind,
|
|
25
|
+
* source_connection_id, source_ref)` for discover-time idempotency.
|
|
26
|
+
*
|
|
27
|
+
* The `projection` JSONB column is the canonical local copy of what
|
|
28
|
+
* `adapter.discover()` returned. Read it for thin-content synthesis (when
|
|
29
|
+
* the adapter declares `supportsContentFetch: false`) and for
|
|
30
|
+
* provenance-aware dispatch — never the search index. Search indexes are
|
|
31
|
+
* optimized for full-text/facet queries, not point-reads of rich detail.
|
|
32
|
+
*/
|
|
33
|
+
export const catalogSourcedEntriesTable = pgTable("catalog_sourced_entries", {
|
|
34
|
+
id: typeId("catalog_sourced_entries"),
|
|
35
|
+
// Voyant-side identity (which vertical, which entity).
|
|
36
|
+
entity_module: text("entity_module").notNull(),
|
|
37
|
+
entity_id: text("entity_id").notNull(),
|
|
38
|
+
// Provenance — mirrors `Provenance` interface, made durable.
|
|
39
|
+
source_kind: text("source_kind").$type().notNull(),
|
|
40
|
+
source_provider: text("source_provider"),
|
|
41
|
+
source_connection_id: text("source_connection_id"),
|
|
42
|
+
source_ref: text("source_ref"),
|
|
43
|
+
source_freshness: text("source_freshness").$type().notNull(),
|
|
44
|
+
last_sourced_at: timestamp("last_sourced_at", { withTimezone: true }),
|
|
45
|
+
// Lifecycle.
|
|
46
|
+
status: text("status").$type().notNull().default("active"),
|
|
47
|
+
first_seen_at: timestamp("first_seen_at", { withTimezone: true }).notNull().defaultNow(),
|
|
48
|
+
last_seen_at: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(),
|
|
49
|
+
// Indexed-projection capture: denormalized snapshot of what
|
|
50
|
+
// `discover()` returned, persisted locally so the thin-content
|
|
51
|
+
// synthesizer and read service have a canonical source — NOT a search
|
|
52
|
+
// index round-trip.
|
|
53
|
+
projection: jsonb("projection").$type().notNull(),
|
|
54
|
+
projection_etag: text("projection_etag"),
|
|
55
|
+
projection_seen_at: timestamp("projection_seen_at", { withTimezone: true })
|
|
56
|
+
.notNull()
|
|
57
|
+
.defaultNow(),
|
|
58
|
+
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
59
|
+
updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
60
|
+
}, (table) => [
|
|
61
|
+
// Voyant-side lookup: one entity per (module, id).
|
|
62
|
+
uniqueIndex("catalog_sourced_entries_entity_uniq").on(table.entity_module, table.entity_id),
|
|
63
|
+
// Upstream-side idempotency: one row per (kind, connection, source_ref).
|
|
64
|
+
// Conditional partial-uniqueness skipped — discover() should always
|
|
65
|
+
// emit all three components for a sourced row, and the entity-uniq
|
|
66
|
+
// covers the rare case where source_ref is null.
|
|
67
|
+
uniqueIndex("catalog_sourced_entries_source_uniq").on(table.source_kind, table.source_connection_id, table.source_ref),
|
|
68
|
+
// Per-vertical × source listings.
|
|
69
|
+
index("catalog_sourced_entries_module_kind_idx").on(table.entity_module, table.source_kind),
|
|
70
|
+
// Withdrawal sweepers — find rows that haven't been seen in N days.
|
|
71
|
+
index("catalog_sourced_entries_status_seen_idx").on(table.status, table.last_seen_at),
|
|
72
|
+
// Per-connection age — useful for "what hasn't this connection
|
|
73
|
+
// refreshed lately" diagnostics.
|
|
74
|
+
index("catalog_sourced_entries_connection_age_idx").on(table.source_kind, table.source_connection_id, table.last_sourced_at),
|
|
75
|
+
]);
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregated drizzle table exports for the catalog plane.
|
|
3
|
+
*
|
|
4
|
+
* Templates point their `drizzle.config.ts` at this single module to pick
|
|
5
|
+
* up every catalog-plane table in one go. Keeps the template config small
|
|
6
|
+
* and avoids the template having to track which catalog-plane sub-paths
|
|
7
|
+
* carry tables.
|
|
8
|
+
*
|
|
9
|
+
* Tables exported:
|
|
10
|
+
* - `catalogOverlayTable` — editorial overrides (§5.2)
|
|
11
|
+
* - `bookingCatalogSnapshotTable` — frozen booking snapshots (§5.3)
|
|
12
|
+
* - `catalogQuotesTable` — booking-engine quote records
|
|
13
|
+
* - `catalogSourcedEntriesTable` — durable sourced-entry store
|
|
14
|
+
* (sourced-content §2.5)
|
|
15
|
+
*/
|
|
16
|
+
export { bookingDraftsTable, type InsertBookingDraft, type SelectBookingDraft, } from "./booking-engine/drafts-schema.js";
|
|
17
|
+
export { catalogQuotesTable, type InsertCatalogQuote, type SelectCatalogQuote, } from "./booking-engine/schema.js";
|
|
18
|
+
export { catalogOverlayTable, type InsertCatalogOverlay, OVERLAY_DEFAULT_SCOPE, type OverlayOrigin, type SelectCatalogOverlay, } from "./overlay/schema.js";
|
|
19
|
+
export { catalogSourcedEntriesTable, type InsertCatalogSourcedEntry, type SelectCatalogSourcedEntry, type SourcedEntryStatus, } from "./schema-sourced-entries.js";
|
|
20
|
+
export { bookingCatalogSnapshotTable, type InsertBookingCatalogSnapshot, type PricingBasis, readPricingBasis, type SelectBookingCatalogSnapshot, } from "./snapshot/schema.js";
|
|
21
|
+
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EACL,kBAAkB,EAClB,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,GACxB,MAAM,mCAAmC,CAAA;AAC1C,OAAO,EACL,kBAAkB,EAClB,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,GACxB,MAAM,4BAA4B,CAAA;AACnC,OAAO,EACL,mBAAmB,EACnB,KAAK,oBAAoB,EACzB,qBAAqB,EACrB,KAAK,aAAa,EAClB,KAAK,oBAAoB,GAC1B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACL,0BAA0B,EAC1B,KAAK,yBAAyB,EAC9B,KAAK,yBAAyB,EAC9B,KAAK,kBAAkB,GACxB,MAAM,6BAA6B,CAAA;AACpC,OAAO,EACL,2BAA2B,EAC3B,KAAK,4BAA4B,EACjC,KAAK,YAAY,EACjB,gBAAgB,EAChB,KAAK,4BAA4B,GAClC,MAAM,sBAAsB,CAAA"}
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregated drizzle table exports for the catalog plane.
|
|
3
|
+
*
|
|
4
|
+
* Templates point their `drizzle.config.ts` at this single module to pick
|
|
5
|
+
* up every catalog-plane table in one go. Keeps the template config small
|
|
6
|
+
* and avoids the template having to track which catalog-plane sub-paths
|
|
7
|
+
* carry tables.
|
|
8
|
+
*
|
|
9
|
+
* Tables exported:
|
|
10
|
+
* - `catalogOverlayTable` — editorial overrides (§5.2)
|
|
11
|
+
* - `bookingCatalogSnapshotTable` — frozen booking snapshots (§5.3)
|
|
12
|
+
* - `catalogQuotesTable` — booking-engine quote records
|
|
13
|
+
* - `catalogSourcedEntriesTable` — durable sourced-entry store
|
|
14
|
+
* (sourced-content §2.5)
|
|
15
|
+
*/
|
|
16
|
+
export { bookingDraftsTable, } from "./booking-engine/drafts-schema.js";
|
|
17
|
+
export { catalogQuotesTable, } from "./booking-engine/schema.js";
|
|
18
|
+
export { catalogOverlayTable, OVERLAY_DEFAULT_SCOPE, } from "./overlay/schema.js";
|
|
19
|
+
export { catalogSourcedEntriesTable, } from "./schema-sourced-entries.js";
|
|
20
|
+
export { bookingCatalogSnapshotTable, readPricingBasis, } from "./snapshot/schema.js";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-audience federated search.
|
|
3
|
+
*
|
|
4
|
+
* Per architecture §7, vectors are strictly per-audience — customer
|
|
5
|
+
* embedding pools only contain customer-visible text, partner pools only
|
|
6
|
+
* contain partner-visible text, etc. This means the most common admin AI
|
|
7
|
+
* use case ("find products similar to *X*" where *X* is in customer-
|
|
8
|
+
* facing language) needs to query a non-staff audience pool.
|
|
9
|
+
*
|
|
10
|
+
* Staff actors are authorized to query any audience pool; customer /
|
|
11
|
+
* partner / supplier agents are pinned to their own audience by API
|
|
12
|
+
* authorization. This helper takes a list of `search_audiences` and:
|
|
13
|
+
*
|
|
14
|
+
* 1. Verifies the actor is authorized for each requested audience.
|
|
15
|
+
* 2. Issues parallel `IndexerAdapter.search` calls — one per audience.
|
|
16
|
+
* 3. Deduplicates hits by entity id (same entity may rank in multiple
|
|
17
|
+
* pools; keep the highest-scoring instance).
|
|
18
|
+
* 4. Merges the per-pool result sets into a single ranked list.
|
|
19
|
+
*
|
|
20
|
+
* If the adapter declares `supportsCrossAudienceFederation`, the helper
|
|
21
|
+
* delegates to a single multi-collection adapter call instead of fanning
|
|
22
|
+
* out client-side. Either way the API contract is the same to callers.
|
|
23
|
+
*
|
|
24
|
+
* See `docs/architecture/catalog-architecture.md` for the design.
|
|
25
|
+
*/
|
|
26
|
+
import type { Visibility } from "../contract.js";
|
|
27
|
+
import type { IndexerAdapter, SearchRequest, SearchResults } from "../indexer/contract.js";
|
|
28
|
+
export interface FederatedSearchOptions {
|
|
29
|
+
adapter: IndexerAdapter;
|
|
30
|
+
/**
|
|
31
|
+
* The actor making the request. The federation helper enforces:
|
|
32
|
+
* - `customer` / `partner` / `supplier` actors → may search only
|
|
33
|
+
* their own audience pool (no federation).
|
|
34
|
+
* - `staff` actors → may search any combination of audience pools.
|
|
35
|
+
*/
|
|
36
|
+
actor: Visibility;
|
|
37
|
+
/** The audience pools to federate across. Must be a subset of allowed pools per actor. */
|
|
38
|
+
searchAudiences: Visibility[];
|
|
39
|
+
/** The vertical (entity_module) to search. */
|
|
40
|
+
vertical: string;
|
|
41
|
+
/** Locale + market for every slice. */
|
|
42
|
+
locale: string;
|
|
43
|
+
market: string;
|
|
44
|
+
/** The base search request — same shape passed to a single-slice search. */
|
|
45
|
+
request: SearchRequest;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Federate a search across multiple audience pools. Returns a unified
|
|
49
|
+
* `SearchResults` with deduplicated hits ranked by score.
|
|
50
|
+
*/
|
|
51
|
+
export declare function federateAudienceSearch(options: FederatedSearchOptions): Promise<SearchResults>;
|
|
52
|
+
/**
|
|
53
|
+
* Merge several `SearchResults` into one, deduplicating by hit id and
|
|
54
|
+
* keeping the highest-scoring instance. Total is the count of unique ids
|
|
55
|
+
* across all pools (after dedupe).
|
|
56
|
+
*/
|
|
57
|
+
export declare function mergeAndDedupe(perSlice: ReadonlyArray<SearchResults>, limit?: number): SearchResults;
|
|
58
|
+
//# sourceMappingURL=federate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"federate.d.ts","sourceRoot":"","sources":["../../src/search/federate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAChD,OAAO,KAAK,EACV,cAAc,EAGd,aAAa,EACb,aAAa,EACd,MAAM,wBAAwB,CAAA;AAE/B,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,cAAc,CAAA;IACvB;;;;;OAKG;IACH,KAAK,EAAE,UAAU,CAAA;IACjB,0FAA0F;IAC1F,eAAe,EAAE,UAAU,EAAE,CAAA;IAC7B,8CAA8C;IAC9C,QAAQ,EAAE,MAAM,CAAA;IAChB,uCAAuC;IACvC,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,4EAA4E;IAC5E,OAAO,EAAE,aAAa,CAAA;CACvB;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,aAAa,CAAC,CAqCxB;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,aAAa,CAAC,aAAa,CAAC,EACtC,KAAK,CAAC,EAAE,MAAM,GACb,aAAa,CAsBf"}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-audience federated search.
|
|
3
|
+
*
|
|
4
|
+
* Per architecture §7, vectors are strictly per-audience — customer
|
|
5
|
+
* embedding pools only contain customer-visible text, partner pools only
|
|
6
|
+
* contain partner-visible text, etc. This means the most common admin AI
|
|
7
|
+
* use case ("find products similar to *X*" where *X* is in customer-
|
|
8
|
+
* facing language) needs to query a non-staff audience pool.
|
|
9
|
+
*
|
|
10
|
+
* Staff actors are authorized to query any audience pool; customer /
|
|
11
|
+
* partner / supplier agents are pinned to their own audience by API
|
|
12
|
+
* authorization. This helper takes a list of `search_audiences` and:
|
|
13
|
+
*
|
|
14
|
+
* 1. Verifies the actor is authorized for each requested audience.
|
|
15
|
+
* 2. Issues parallel `IndexerAdapter.search` calls — one per audience.
|
|
16
|
+
* 3. Deduplicates hits by entity id (same entity may rank in multiple
|
|
17
|
+
* pools; keep the highest-scoring instance).
|
|
18
|
+
* 4. Merges the per-pool result sets into a single ranked list.
|
|
19
|
+
*
|
|
20
|
+
* If the adapter declares `supportsCrossAudienceFederation`, the helper
|
|
21
|
+
* delegates to a single multi-collection adapter call instead of fanning
|
|
22
|
+
* out client-side. Either way the API contract is the same to callers.
|
|
23
|
+
*
|
|
24
|
+
* See `docs/architecture/catalog-architecture.md` for the design.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Federate a search across multiple audience pools. Returns a unified
|
|
28
|
+
* `SearchResults` with deduplicated hits ranked by score.
|
|
29
|
+
*/
|
|
30
|
+
export async function federateAudienceSearch(options) {
|
|
31
|
+
const { adapter, actor, searchAudiences, vertical, locale, market, request } = options;
|
|
32
|
+
if (searchAudiences.length === 0) {
|
|
33
|
+
throw new Error("federateAudienceSearch requires at least one searchAudience");
|
|
34
|
+
}
|
|
35
|
+
enforceAudienceAuthorization(actor, searchAudiences);
|
|
36
|
+
// If a single audience is requested, no federation needed — direct search.
|
|
37
|
+
if (searchAudiences.length === 1) {
|
|
38
|
+
const audience = searchAudiences[0];
|
|
39
|
+
const slice = { vertical, locale, audience, market };
|
|
40
|
+
return adapter.search(slice, request);
|
|
41
|
+
}
|
|
42
|
+
// Engine-side federation when supported — single adapter call.
|
|
43
|
+
if (adapter.capabilities.supportsCrossAudienceFederation) {
|
|
44
|
+
// The adapter contract doesn't currently expose multi-slice search
|
|
45
|
+
// directly. For Phase 2 v1 we use the client-side fan-out path below
|
|
46
|
+
// even when the engine could do better. A follow-up adds a
|
|
47
|
+
// `searchFederated(slices: IndexerSlice[], request)` method to the
|
|
48
|
+
// contract to take advantage of native federation.
|
|
49
|
+
// For now, fall through to the client-side path.
|
|
50
|
+
}
|
|
51
|
+
// Client-side fan-out: one search per audience pool, parallelized.
|
|
52
|
+
const slices = searchAudiences.map((audience) => ({
|
|
53
|
+
vertical,
|
|
54
|
+
locale,
|
|
55
|
+
audience,
|
|
56
|
+
market,
|
|
57
|
+
}));
|
|
58
|
+
const perSliceResults = await Promise.all(slices.map((slice) => adapter.search(slice, request)));
|
|
59
|
+
return mergeAndDedupe(perSliceResults, request.pagination?.limit);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Merge several `SearchResults` into one, deduplicating by hit id and
|
|
63
|
+
* keeping the highest-scoring instance. Total is the count of unique ids
|
|
64
|
+
* across all pools (after dedupe).
|
|
65
|
+
*/
|
|
66
|
+
export function mergeAndDedupe(perSlice, limit) {
|
|
67
|
+
const byId = new Map();
|
|
68
|
+
for (const result of perSlice) {
|
|
69
|
+
for (const hit of result.hits) {
|
|
70
|
+
const existing = byId.get(hit.id);
|
|
71
|
+
if (!existing || hit.score > existing.score) {
|
|
72
|
+
byId.set(hit.id, hit);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Sort by score descending — federated semantics rank by best score.
|
|
77
|
+
const merged = Array.from(byId.values()).sort((a, b) => b.score - a.score);
|
|
78
|
+
const limited = limit != null ? merged.slice(0, limit) : merged;
|
|
79
|
+
return {
|
|
80
|
+
hits: limited,
|
|
81
|
+
total: byId.size,
|
|
82
|
+
// Facets aren't merged — federation across audiences with different
|
|
83
|
+
// facet vocabularies is ambiguous. Callers that need facets should
|
|
84
|
+
// search a single audience.
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Enforce per-actor authorization rules: customer / partner / supplier
|
|
89
|
+
* agents may only search their own audience pool. Staff actors may
|
|
90
|
+
* federate across any pools.
|
|
91
|
+
*/
|
|
92
|
+
function enforceAudienceAuthorization(actor, requested) {
|
|
93
|
+
if (actor === "staff") {
|
|
94
|
+
// Staff may federate across anything.
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (requested.length === 1 && requested[0] === actor) {
|
|
98
|
+
// Non-staff actor searching their own pool only — allowed.
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
throw new Error(`Actor "${actor}" is not authorized to federate across audiences ${JSON.stringify(requested)}. ` +
|
|
102
|
+
`Non-staff actors may only search their own audience pool. To federate across audiences, the request must come from a staff actor.`);
|
|
103
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"federate.test.d.ts","sourceRoot":"","sources":["../../src/search/federate.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { federateAudienceSearch, mergeAndDedupe } from "./federate.js";
|
|
3
|
+
const capabilities = {
|
|
4
|
+
supportsKeywordSearch: true,
|
|
5
|
+
supportsHybridSearch: true,
|
|
6
|
+
supportsVectorFields: true,
|
|
7
|
+
vectorDimensions: 1536,
|
|
8
|
+
maxVectorsPerDocument: null,
|
|
9
|
+
supportsCrossAudienceFederation: false,
|
|
10
|
+
supportsAdminDenormalization: true,
|
|
11
|
+
};
|
|
12
|
+
function hit(id, score) {
|
|
13
|
+
return { id, score, document: { id, fields: {} } };
|
|
14
|
+
}
|
|
15
|
+
function makeAdapter(perAudience) {
|
|
16
|
+
return {
|
|
17
|
+
capabilities,
|
|
18
|
+
async ensureCollection() { },
|
|
19
|
+
async upsert() { },
|
|
20
|
+
async delete() { },
|
|
21
|
+
async bulkReindex() { },
|
|
22
|
+
async search(slice) {
|
|
23
|
+
const hits = perAudience[slice.audience] ?? [];
|
|
24
|
+
return { hits, total: hits.length };
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
describe("mergeAndDedupe", () => {
|
|
29
|
+
it("keeps the highest-scoring instance when an entity appears in multiple pools", () => {
|
|
30
|
+
const merged = mergeAndDedupe([
|
|
31
|
+
{ hits: [hit("a", 1), hit("b", 2)], total: 2 },
|
|
32
|
+
{ hits: [hit("a", 5), hit("c", 3)], total: 2 },
|
|
33
|
+
]);
|
|
34
|
+
const ids = merged.hits.map((h) => h.id);
|
|
35
|
+
expect(ids).toEqual(["a", "c", "b"]); // sorted by score desc
|
|
36
|
+
const aHit = merged.hits.find((h) => h.id === "a");
|
|
37
|
+
expect(aHit?.score).toBe(5);
|
|
38
|
+
expect(merged.total).toBe(3);
|
|
39
|
+
});
|
|
40
|
+
it("respects the limit parameter", () => {
|
|
41
|
+
const merged = mergeAndDedupe([{ hits: [hit("a", 1), hit("b", 2), hit("c", 3)], total: 3 }], 2);
|
|
42
|
+
expect(merged.hits).toHaveLength(2);
|
|
43
|
+
expect(merged.total).toBe(3); // total is unique-id count, not limited
|
|
44
|
+
});
|
|
45
|
+
it("returns an empty result when no pools have hits", () => {
|
|
46
|
+
const merged = mergeAndDedupe([]);
|
|
47
|
+
expect(merged.hits).toEqual([]);
|
|
48
|
+
expect(merged.total).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe("federateAudienceSearch — authorization", () => {
|
|
52
|
+
it("rejects non-staff actors trying to federate beyond their own pool", async () => {
|
|
53
|
+
const adapter = makeAdapter({});
|
|
54
|
+
await expect(federateAudienceSearch({
|
|
55
|
+
adapter,
|
|
56
|
+
actor: "customer",
|
|
57
|
+
searchAudiences: ["customer", "partner"],
|
|
58
|
+
vertical: "products",
|
|
59
|
+
locale: "en-GB",
|
|
60
|
+
market: "default",
|
|
61
|
+
request: { query: "x", mode: "keyword" },
|
|
62
|
+
})).rejects.toThrow(/not authorized to federate/);
|
|
63
|
+
});
|
|
64
|
+
it("allows non-staff actors to search their own audience pool only", async () => {
|
|
65
|
+
const adapter = makeAdapter({ customer: [hit("a", 1)] });
|
|
66
|
+
const result = await federateAudienceSearch({
|
|
67
|
+
adapter,
|
|
68
|
+
actor: "customer",
|
|
69
|
+
searchAudiences: ["customer"],
|
|
70
|
+
vertical: "products",
|
|
71
|
+
locale: "en-GB",
|
|
72
|
+
market: "default",
|
|
73
|
+
request: { query: "x", mode: "keyword" },
|
|
74
|
+
});
|
|
75
|
+
expect(result.hits).toHaveLength(1);
|
|
76
|
+
});
|
|
77
|
+
it("rejects non-staff actors trying to search a different audience pool", async () => {
|
|
78
|
+
const adapter = makeAdapter({});
|
|
79
|
+
await expect(federateAudienceSearch({
|
|
80
|
+
adapter,
|
|
81
|
+
actor: "customer",
|
|
82
|
+
searchAudiences: ["partner"],
|
|
83
|
+
vertical: "products",
|
|
84
|
+
locale: "en-GB",
|
|
85
|
+
market: "default",
|
|
86
|
+
request: { query: "x", mode: "keyword" },
|
|
87
|
+
})).rejects.toThrow(/not authorized to federate/);
|
|
88
|
+
});
|
|
89
|
+
it("allows staff actors to federate across multiple audience pools", async () => {
|
|
90
|
+
const adapter = makeAdapter({
|
|
91
|
+
customer: [hit("a", 1)],
|
|
92
|
+
partner: [hit("b", 2)],
|
|
93
|
+
});
|
|
94
|
+
const result = await federateAudienceSearch({
|
|
95
|
+
adapter,
|
|
96
|
+
actor: "staff",
|
|
97
|
+
searchAudiences: ["customer", "partner"],
|
|
98
|
+
vertical: "products",
|
|
99
|
+
locale: "en-GB",
|
|
100
|
+
market: "default",
|
|
101
|
+
request: { query: "x", mode: "keyword" },
|
|
102
|
+
});
|
|
103
|
+
expect(result.hits.map((h) => h.id).sort()).toEqual(["a", "b"]);
|
|
104
|
+
expect(result.total).toBe(2);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
describe("federateAudienceSearch — single audience shortcut", () => {
|
|
108
|
+
it("issues a single adapter call when only one audience is requested", async () => {
|
|
109
|
+
let callCount = 0;
|
|
110
|
+
const wrapped = {
|
|
111
|
+
capabilities,
|
|
112
|
+
async ensureCollection() { },
|
|
113
|
+
async upsert() { },
|
|
114
|
+
async delete() { },
|
|
115
|
+
async bulkReindex() { },
|
|
116
|
+
async search(slice) {
|
|
117
|
+
callCount++;
|
|
118
|
+
return { hits: [hit(`from-${slice.audience}`, 1)], total: 1 };
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
await federateAudienceSearch({
|
|
122
|
+
adapter: wrapped,
|
|
123
|
+
actor: "staff",
|
|
124
|
+
searchAudiences: ["customer"],
|
|
125
|
+
vertical: "products",
|
|
126
|
+
locale: "en-GB",
|
|
127
|
+
market: "default",
|
|
128
|
+
request: { query: "x", mode: "keyword" },
|
|
129
|
+
});
|
|
130
|
+
expect(callCount).toBe(1);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
describe("federateAudienceSearch — empty input rejected", () => {
|
|
134
|
+
it("requires at least one searchAudience", async () => {
|
|
135
|
+
const adapter = makeAdapter({});
|
|
136
|
+
await expect(federateAudienceSearch({
|
|
137
|
+
adapter,
|
|
138
|
+
actor: "staff",
|
|
139
|
+
searchAudiences: [],
|
|
140
|
+
vertical: "products",
|
|
141
|
+
locale: "en-GB",
|
|
142
|
+
market: "default",
|
|
143
|
+
request: { query: "x", mode: "keyword" },
|
|
144
|
+
})).rejects.toThrow(/at least one searchAudience/);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 2 two-stage-search rerank helper for browse-time pricing.
|
|
3
|
+
*
|
|
4
|
+
* Storefront BFFs use this for "refresh prices for these dates" actions and
|
|
5
|
+
* other high-intent queries where Tier 1 indexed price summaries are too
|
|
6
|
+
* approximate. The pattern: indexer returns top-N candidates ranked by
|
|
7
|
+
* cached price; this helper calls the source adapter's live-pricing
|
|
8
|
+
* operation for those candidates with the customer's exact dates /
|
|
9
|
+
* occupancy / market / currency, then resorts.
|
|
10
|
+
*
|
|
11
|
+
* v1 ships the helper but storefronts opt in per query — never always-on.
|
|
12
|
+
*
|
|
13
|
+
* See `docs/architecture/catalog-architecture.md` §5.4.3 Tier 2 for design.
|
|
14
|
+
*/
|
|
15
|
+
import type { SearchHit, SearchResults } from "../indexer/contract.js";
|
|
16
|
+
/**
|
|
17
|
+
* The live-pricing function the storefront BFF supplies. Receives a batch
|
|
18
|
+
* of candidate ids (typically top-N from the indexer) plus the rerank
|
|
19
|
+
* parameters (dates / occupancy / market / currency); returns a per-id
|
|
20
|
+
* price map.
|
|
21
|
+
*
|
|
22
|
+
* Implementations route per-id calls to the right source adapter — the BFF
|
|
23
|
+
* knows which adapter owns each entity from the catalog plane's provenance.
|
|
24
|
+
*/
|
|
25
|
+
export type LivePriceFn = (ids: string[], parameters: RerankParameters) => Promise<Map<string, LivePriceResult>>;
|
|
26
|
+
/** Parameters that scope the live-pricing rerank. Shape is vertical-agnostic. */
|
|
27
|
+
export interface RerankParameters {
|
|
28
|
+
dates?: {
|
|
29
|
+
start: string;
|
|
30
|
+
end: string;
|
|
31
|
+
};
|
|
32
|
+
occupancy?: Record<string, number>;
|
|
33
|
+
market: string;
|
|
34
|
+
currency: string;
|
|
35
|
+
/** Per-source timeout in milliseconds. Defaults vary by storefront. */
|
|
36
|
+
perSourceTimeoutMs?: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Result of a single live-pricing call. The price source is captured so
|
|
40
|
+
* downstream consumers can mark stale fallbacks.
|
|
41
|
+
*/
|
|
42
|
+
export interface LivePriceResult {
|
|
43
|
+
amount: number;
|
|
44
|
+
currency: string;
|
|
45
|
+
source: "live" | "stale";
|
|
46
|
+
}
|
|
47
|
+
/** Reranked hit with the price provenance attached. */
|
|
48
|
+
export interface RerankedHit extends SearchHit {
|
|
49
|
+
/** The live-resolved price; may be the indexed fallback if live timed out. */
|
|
50
|
+
rerankedPrice?: LivePriceResult;
|
|
51
|
+
}
|
|
52
|
+
export interface RerankOptions {
|
|
53
|
+
/**
|
|
54
|
+
* Top-N to pass through the live-pricing path. Default 100; bounded so the
|
|
55
|
+
* rerank cost stays predictable.
|
|
56
|
+
*/
|
|
57
|
+
topN?: number;
|
|
58
|
+
/**
|
|
59
|
+
* If true, hits without a successful live price drop to the bottom of the
|
|
60
|
+
* sorted result. If false (default), they keep their indexer-ranked order
|
|
61
|
+
* but carry a `priceSource: "stale"` marker in `rerankedPrice`.
|
|
62
|
+
*/
|
|
63
|
+
dropOnLiveMiss?: boolean;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Reranks indexer results against live source pricing. The hit list is
|
|
67
|
+
* narrowed to `topN`, live-priced via the supplied function, resorted by
|
|
68
|
+
* live price, and merged back with the un-reranked tail (which keeps its
|
|
69
|
+
* indexer ranking).
|
|
70
|
+
*
|
|
71
|
+
* Failures from the live function are caught individually — one timing-out
|
|
72
|
+
* source does not tank the rerank.
|
|
73
|
+
*/
|
|
74
|
+
export declare function rerank(results: SearchResults, livePrice: LivePriceFn, parameters: RerankParameters, options?: RerankOptions): Promise<SearchResults & {
|
|
75
|
+
hits: RerankedHit[];
|
|
76
|
+
}>;
|
|
77
|
+
//# sourceMappingURL=rerank.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rerank.d.ts","sourceRoot":"","sources":["../../src/search/rerank.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAEtE;;;;;;;;GAQG;AACH,MAAM,MAAM,WAAW,GAAG,CACxB,GAAG,EAAE,MAAM,EAAE,EACb,UAAU,EAAE,gBAAgB,KACzB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC,CAAA;AAE1C,iFAAiF;AACjF,MAAM,WAAW,gBAAgB;IAC/B,KAAK,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAA;IACtC,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAClC,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,uEAAuE;IACvE,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,GAAG,OAAO,CAAA;CACzB;AAED,uDAAuD;AACvD,MAAM,WAAW,WAAY,SAAQ,SAAS;IAC5C,8EAA8E;IAC9E,aAAa,CAAC,EAAE,eAAe,CAAA;CAChC;AAED,MAAM,WAAW,aAAa;IAC5B;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED;;;;;;;;GAQG;AACH,wBAAsB,MAAM,CAC1B,OAAO,EAAE,aAAa,EACtB,SAAS,EAAE,WAAW,EACtB,UAAU,EAAE,gBAAgB,EAC5B,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,aAAa,GAAG;IAAE,IAAI,EAAE,WAAW,EAAE,CAAA;CAAE,CAAC,CA8ClD"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 2 two-stage-search rerank helper for browse-time pricing.
|
|
3
|
+
*
|
|
4
|
+
* Storefront BFFs use this for "refresh prices for these dates" actions and
|
|
5
|
+
* other high-intent queries where Tier 1 indexed price summaries are too
|
|
6
|
+
* approximate. The pattern: indexer returns top-N candidates ranked by
|
|
7
|
+
* cached price; this helper calls the source adapter's live-pricing
|
|
8
|
+
* operation for those candidates with the customer's exact dates /
|
|
9
|
+
* occupancy / market / currency, then resorts.
|
|
10
|
+
*
|
|
11
|
+
* v1 ships the helper but storefronts opt in per query — never always-on.
|
|
12
|
+
*
|
|
13
|
+
* See `docs/architecture/catalog-architecture.md` §5.4.3 Tier 2 for design.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Reranks indexer results against live source pricing. The hit list is
|
|
17
|
+
* narrowed to `topN`, live-priced via the supplied function, resorted by
|
|
18
|
+
* live price, and merged back with the un-reranked tail (which keeps its
|
|
19
|
+
* indexer ranking).
|
|
20
|
+
*
|
|
21
|
+
* Failures from the live function are caught individually — one timing-out
|
|
22
|
+
* source does not tank the rerank.
|
|
23
|
+
*/
|
|
24
|
+
export async function rerank(results, livePrice, parameters, options = {}) {
|
|
25
|
+
const topN = options.topN ?? 100;
|
|
26
|
+
const head = results.hits.slice(0, topN);
|
|
27
|
+
const tail = results.hits.slice(topN);
|
|
28
|
+
const headIds = head.map((h) => h.id);
|
|
29
|
+
let priceMap;
|
|
30
|
+
try {
|
|
31
|
+
priceMap = await livePrice(headIds, parameters);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Live call failed entirely. Return the indexer-ranked results unchanged.
|
|
35
|
+
return { ...results, hits: head.concat(tail) };
|
|
36
|
+
}
|
|
37
|
+
// Attach prices to hits.
|
|
38
|
+
const reranked = head.map((hit) => {
|
|
39
|
+
const price = priceMap.get(hit.id);
|
|
40
|
+
return price ? { ...hit, rerankedPrice: price } : { ...hit };
|
|
41
|
+
});
|
|
42
|
+
// Filter / order:
|
|
43
|
+
// - hits with live prices sort by price ascending
|
|
44
|
+
// - hits with stale fallback sort after the live ones (stable order)
|
|
45
|
+
// - hits with no price are dropped or kept based on dropOnLiveMiss
|
|
46
|
+
const live = [];
|
|
47
|
+
const stale = [];
|
|
48
|
+
const missing = [];
|
|
49
|
+
for (const hit of reranked) {
|
|
50
|
+
if (!hit.rerankedPrice) {
|
|
51
|
+
missing.push(hit);
|
|
52
|
+
}
|
|
53
|
+
else if (hit.rerankedPrice.source === "live") {
|
|
54
|
+
live.push(hit);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
stale.push(hit);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
live.sort((a, b) => a.rerankedPrice.amount - b.rerankedPrice.amount);
|
|
61
|
+
const headOut = options.dropOnLiveMiss
|
|
62
|
+
? [...live, ...stale]
|
|
63
|
+
: [...live, ...stale, ...missing];
|
|
64
|
+
return {
|
|
65
|
+
...results,
|
|
66
|
+
hits: [...headOut, ...tail],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rerank.test.d.ts","sourceRoot":"","sources":["../../src/search/rerank.test.ts"],"names":[],"mappings":""}
|