@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,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlay resolver — applies editorial overlays to a source projection,
|
|
3
|
+
* walking the variant fallback chain.
|
|
4
|
+
*
|
|
5
|
+
* Variant axes are `locale`, `audience`, `market`. The resolver walks an
|
|
6
|
+
* 8-step fallback (most-specific → least-specific) and applies the matching
|
|
7
|
+
* overlay value using the merge rule from the field policy.
|
|
8
|
+
*
|
|
9
|
+
* Pure logic: no DB access, no IO. Callers fetch overlays by entity in one
|
|
10
|
+
* query, pass them in here as `OverlayLookup`, and get back the resolved
|
|
11
|
+
* field value tree.
|
|
12
|
+
*
|
|
13
|
+
* See `docs/architecture/catalog-architecture.md` §5.2.1 for the fallback
|
|
14
|
+
* chain and §7.1 for the split rule that keeps merging predictable.
|
|
15
|
+
*/
|
|
16
|
+
import type { FieldPolicy, FieldPolicyRegistry, Visibility } from "../contract.js";
|
|
17
|
+
import { OVERLAY_DEFAULT_SCOPE } from "./schema.js";
|
|
18
|
+
/** A single overlay row reduced to what the resolver needs. */
|
|
19
|
+
export interface ResolverOverlay {
|
|
20
|
+
field_path: string;
|
|
21
|
+
locale: string;
|
|
22
|
+
audience: Visibility | typeof OVERLAY_DEFAULT_SCOPE;
|
|
23
|
+
market: string;
|
|
24
|
+
value: unknown;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The variant-scoped query the caller is asking the resolver to satisfy.
|
|
28
|
+
* Returned values reflect overlays applicable to this exact tuple, with
|
|
29
|
+
* fallbacks down through `default` sentinels.
|
|
30
|
+
*/
|
|
31
|
+
export interface ResolverScope {
|
|
32
|
+
locale: string;
|
|
33
|
+
audience: Visibility;
|
|
34
|
+
market: string;
|
|
35
|
+
/** The actor making the request — used for visibility filtering. */
|
|
36
|
+
actor: Visibility;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Set of overlay rows fetched for a single entity. The resolver indexes
|
|
40
|
+
* them internally by `(field_path, locale, audience, market)` for the
|
|
41
|
+
* fallback walk.
|
|
42
|
+
*/
|
|
43
|
+
export type OverlayLookup = ReadonlyArray<ResolverOverlay>;
|
|
44
|
+
/**
|
|
45
|
+
* Resolved-view emitter. Given a per-entity source projection and the
|
|
46
|
+
* applicable overlays, returns the per-(field_path → value) map after
|
|
47
|
+
* applying the variant fallback chain, the merge rule, and the visibility
|
|
48
|
+
* filter for the requesting actor.
|
|
49
|
+
*/
|
|
50
|
+
export interface ResolvedView {
|
|
51
|
+
/** Resolved values keyed by field path. */
|
|
52
|
+
values: Map<string, unknown>;
|
|
53
|
+
/**
|
|
54
|
+
* Fields the resolver intentionally omitted because they are not visible
|
|
55
|
+
* to the requesting actor. Useful for debug / "preview as audience X"
|
|
56
|
+
* views; consumers should not display these.
|
|
57
|
+
*/
|
|
58
|
+
hidden: Set<string>;
|
|
59
|
+
/**
|
|
60
|
+
* Per-field provenance: which variant slice satisfied the lookup. `null`
|
|
61
|
+
* means the source projection's value was used (no overlay applied).
|
|
62
|
+
*/
|
|
63
|
+
provenance: Map<string, ResolvedFieldProvenance | null>;
|
|
64
|
+
}
|
|
65
|
+
export interface ResolvedFieldProvenance {
|
|
66
|
+
locale: string;
|
|
67
|
+
audience: Visibility | typeof OVERLAY_DEFAULT_SCOPE;
|
|
68
|
+
market: string;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The 8-step variant fallback chain, ordered from most-specific to
|
|
72
|
+
* least-specific. The resolver walks this list and stops at the first
|
|
73
|
+
* overlay it finds for the requested field path.
|
|
74
|
+
*/
|
|
75
|
+
export declare function variantFallbackChain(scope: ResolverScope): Array<{
|
|
76
|
+
locale: string;
|
|
77
|
+
audience: string;
|
|
78
|
+
market: string;
|
|
79
|
+
}>;
|
|
80
|
+
/**
|
|
81
|
+
* Applies the field policy's merge rule to combine a source value with an
|
|
82
|
+
* overlay value. Returns the resolved value.
|
|
83
|
+
*
|
|
84
|
+
* Throws if `merge: "source-only"` is configured but an overlay was passed —
|
|
85
|
+
* this means the overlay-write path validation failed and a forbidden
|
|
86
|
+
* override slipped through; refuse to honor it at read time.
|
|
87
|
+
*/
|
|
88
|
+
export declare function applyMerge(policy: FieldPolicy, sourceValue: unknown, overlayValue: unknown): unknown;
|
|
89
|
+
/**
|
|
90
|
+
* Resolves a source projection plus a set of overlays into a final view for
|
|
91
|
+
* the requesting `(locale, audience, market)` scope, filtered by the actor's
|
|
92
|
+
* visibility.
|
|
93
|
+
*
|
|
94
|
+
* The source projection is keyed by field path; only fields present in the
|
|
95
|
+
* registry are considered. Fields whose policy hides them from the actor's
|
|
96
|
+
* audience are placed in `hidden`, not `values`.
|
|
97
|
+
*/
|
|
98
|
+
export declare function resolveOverlay(registry: FieldPolicyRegistry, sourceProjection: ReadonlyMap<string, unknown>, overlays: OverlayLookup, scope: ResolverScope): ResolvedView;
|
|
99
|
+
/** Visibility check: is the field visible to the requesting actor? */
|
|
100
|
+
export declare function isVisibleTo(policy: FieldPolicy, actor: Visibility): boolean;
|
|
101
|
+
//# sourceMappingURL=resolver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolver.d.ts","sourceRoot":"","sources":["../../src/overlay/resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,mBAAmB,EAAa,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAC7F,OAAO,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAA;AAEnD,+DAA+D;AAC/D,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,UAAU,GAAG,OAAO,qBAAqB,CAAA;IACnD,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,OAAO,CAAA;CACf;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,UAAU,CAAA;IACpB,MAAM,EAAE,MAAM,CAAA;IACd,oEAAoE;IACpE,KAAK,EAAE,UAAU,CAAA;CAClB;AAED;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,aAAa,CAAC,eAAe,CAAC,CAAA;AAE1D;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,2CAA2C;IAC3C,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC5B;;;;OAIG;IACH,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACnB;;;OAGG;IACH,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,uBAAuB,GAAG,IAAI,CAAC,CAAA;CACxD;AAED,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,UAAU,GAAG,OAAO,qBAAqB,CAAA;IACnD,MAAM,EAAE,MAAM,CAAA;CACf;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,aAAa,GACnB,KAAK,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAa7D;AAyCD;;;;;;;GAOG;AACH,wBAAgB,UAAU,CACxB,MAAM,EAAE,WAAW,EACnB,WAAW,EAAE,OAAO,EACpB,YAAY,EAAE,OAAO,GACpB,OAAO,CAET;AAiDD;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,mBAAmB,EAC7B,gBAAgB,EAAE,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9C,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,aAAa,GACnB,YAAY,CAoCd;AAED,sEAAsE;AACtE,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,EAAE,KAAK,EAAE,UAAU,GAAG,OAAO,CAE3E"}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlay resolver — applies editorial overlays to a source projection,
|
|
3
|
+
* walking the variant fallback chain.
|
|
4
|
+
*
|
|
5
|
+
* Variant axes are `locale`, `audience`, `market`. The resolver walks an
|
|
6
|
+
* 8-step fallback (most-specific → least-specific) and applies the matching
|
|
7
|
+
* overlay value using the merge rule from the field policy.
|
|
8
|
+
*
|
|
9
|
+
* Pure logic: no DB access, no IO. Callers fetch overlays by entity in one
|
|
10
|
+
* query, pass them in here as `OverlayLookup`, and get back the resolved
|
|
11
|
+
* field value tree.
|
|
12
|
+
*
|
|
13
|
+
* See `docs/architecture/catalog-architecture.md` §5.2.1 for the fallback
|
|
14
|
+
* chain and §7.1 for the split rule that keeps merging predictable.
|
|
15
|
+
*/
|
|
16
|
+
import { OVERLAY_DEFAULT_SCOPE } from "./schema.js";
|
|
17
|
+
/**
|
|
18
|
+
* The 8-step variant fallback chain, ordered from most-specific to
|
|
19
|
+
* least-specific. The resolver walks this list and stops at the first
|
|
20
|
+
* overlay it finds for the requested field path.
|
|
21
|
+
*/
|
|
22
|
+
export function variantFallbackChain(scope) {
|
|
23
|
+
const D = OVERLAY_DEFAULT_SCOPE;
|
|
24
|
+
const { locale, audience, market } = scope;
|
|
25
|
+
return [
|
|
26
|
+
{ locale, audience, market },
|
|
27
|
+
{ locale, audience, market: D },
|
|
28
|
+
{ locale, audience: D, market },
|
|
29
|
+
{ locale, audience: D, market: D },
|
|
30
|
+
{ locale: D, audience, market },
|
|
31
|
+
{ locale: D, audience, market: D },
|
|
32
|
+
{ locale: D, audience: D, market },
|
|
33
|
+
{ locale: D, audience: D, market: D },
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Indexes overlay rows by `(field_path, locale, audience, market)` for the
|
|
38
|
+
* resolver's fallback walk. Idempotent — passing the same overlay twice
|
|
39
|
+
* produces a deterministic last-write-wins (by array position) result.
|
|
40
|
+
*/
|
|
41
|
+
function indexOverlays(overlays) {
|
|
42
|
+
const byField = new Map();
|
|
43
|
+
for (const overlay of overlays) {
|
|
44
|
+
let inner = byField.get(overlay.field_path);
|
|
45
|
+
if (!inner) {
|
|
46
|
+
inner = new Map();
|
|
47
|
+
byField.set(overlay.field_path, inner);
|
|
48
|
+
}
|
|
49
|
+
const key = `${overlay.locale}|${overlay.audience}|${overlay.market}`;
|
|
50
|
+
inner.set(key, overlay);
|
|
51
|
+
}
|
|
52
|
+
return byField;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Looks up the overlay for a single field path under the variant fallback
|
|
56
|
+
* chain. Returns the first match, or `undefined` if no overlay applies at
|
|
57
|
+
* any fallback level.
|
|
58
|
+
*/
|
|
59
|
+
function lookupOverlay(byField, fieldPath, scope) {
|
|
60
|
+
const inner = byField.get(fieldPath);
|
|
61
|
+
if (!inner)
|
|
62
|
+
return undefined;
|
|
63
|
+
for (const variant of variantFallbackChain(scope)) {
|
|
64
|
+
const key = `${variant.locale}|${variant.audience}|${variant.market}`;
|
|
65
|
+
const hit = inner.get(key);
|
|
66
|
+
if (hit)
|
|
67
|
+
return hit;
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Applies the field policy's merge rule to combine a source value with an
|
|
73
|
+
* overlay value. Returns the resolved value.
|
|
74
|
+
*
|
|
75
|
+
* Throws if `merge: "source-only"` is configured but an overlay was passed —
|
|
76
|
+
* this means the overlay-write path validation failed and a forbidden
|
|
77
|
+
* override slipped through; refuse to honor it at read time.
|
|
78
|
+
*/
|
|
79
|
+
export function applyMerge(policy, sourceValue, overlayValue) {
|
|
80
|
+
return mergeByRule(policy.merge, sourceValue, overlayValue, policy.path);
|
|
81
|
+
}
|
|
82
|
+
function mergeByRule(rule, sourceValue, overlayValue, path) {
|
|
83
|
+
switch (rule) {
|
|
84
|
+
case "source-only":
|
|
85
|
+
throw new Error(`field "${path}" has merge: "source-only" but received an overlay value (overlay-write validation should have rejected this)`);
|
|
86
|
+
case "replace":
|
|
87
|
+
return overlayValue;
|
|
88
|
+
case "additive-set": {
|
|
89
|
+
const sourceArray = Array.isArray(sourceValue) ? sourceValue : [];
|
|
90
|
+
const overlayArray = Array.isArray(overlayValue) ? overlayValue : [];
|
|
91
|
+
// Preserve insertion order; first occurrence wins on duplicates.
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
const merged = [];
|
|
94
|
+
for (const item of [...sourceArray, ...overlayArray]) {
|
|
95
|
+
if (!seen.has(item)) {
|
|
96
|
+
seen.add(item);
|
|
97
|
+
merged.push(item);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return merged;
|
|
101
|
+
}
|
|
102
|
+
case "additive-list": {
|
|
103
|
+
const sourceArray = Array.isArray(sourceValue) ? sourceValue : [];
|
|
104
|
+
const overlayArray = Array.isArray(overlayValue) ? overlayValue : [];
|
|
105
|
+
return [...sourceArray, ...overlayArray];
|
|
106
|
+
}
|
|
107
|
+
case "list-position": {
|
|
108
|
+
const sourceArray = Array.isArray(sourceValue) ? [...sourceValue] : [];
|
|
109
|
+
// overlayValue is a sparse array: positions to override.
|
|
110
|
+
const overlay = overlayValue;
|
|
111
|
+
for (const [posStr, value] of Object.entries(overlay)) {
|
|
112
|
+
const pos = Number(posStr);
|
|
113
|
+
if (Number.isInteger(pos) && pos >= 0) {
|
|
114
|
+
sourceArray[pos] = value;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return sourceArray;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Resolves a source projection plus a set of overlays into a final view for
|
|
123
|
+
* the requesting `(locale, audience, market)` scope, filtered by the actor's
|
|
124
|
+
* visibility.
|
|
125
|
+
*
|
|
126
|
+
* The source projection is keyed by field path; only fields present in the
|
|
127
|
+
* registry are considered. Fields whose policy hides them from the actor's
|
|
128
|
+
* audience are placed in `hidden`, not `values`.
|
|
129
|
+
*/
|
|
130
|
+
export function resolveOverlay(registry, sourceProjection, overlays, scope) {
|
|
131
|
+
const indexed = indexOverlays(overlays);
|
|
132
|
+
const values = new Map();
|
|
133
|
+
const hidden = new Set();
|
|
134
|
+
const provenance = new Map();
|
|
135
|
+
for (const [path, sourceValue] of sourceProjection) {
|
|
136
|
+
const policy = registry.resolve(path);
|
|
137
|
+
if (!policy) {
|
|
138
|
+
// Field exists in the source projection but not in the registry. The
|
|
139
|
+
// resolver leaves it out — the registry is authoritative about which
|
|
140
|
+
// fields are part of the catalog projection.
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// Visibility filter: skip fields not visible to the requesting actor.
|
|
144
|
+
if (!isVisibleTo(policy, scope.actor)) {
|
|
145
|
+
hidden.add(path);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const overlay = lookupOverlay(indexed, path, scope);
|
|
149
|
+
if (overlay && policy.merge !== "source-only") {
|
|
150
|
+
values.set(path, applyMerge(policy, sourceValue, overlay.value));
|
|
151
|
+
provenance.set(path, {
|
|
152
|
+
locale: overlay.locale,
|
|
153
|
+
audience: overlay.audience,
|
|
154
|
+
market: overlay.market,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
values.set(path, sourceValue);
|
|
159
|
+
provenance.set(path, null);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { values, hidden, provenance };
|
|
163
|
+
}
|
|
164
|
+
/** Visibility check: is the field visible to the requesting actor? */
|
|
165
|
+
export function isVisibleTo(policy, actor) {
|
|
166
|
+
return policy.visibility.includes(actor);
|
|
167
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolver.test.d.ts","sourceRoot":"","sources":["../../src/overlay/resolver.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createFieldPolicyRegistry, defineFieldPolicy } from "../contract.js";
|
|
3
|
+
import { applyMerge, resolveOverlay, variantFallbackChain, } from "./resolver.js";
|
|
4
|
+
import { OVERLAY_DEFAULT_SCOPE } from "./schema.js";
|
|
5
|
+
const merchandisable = {
|
|
6
|
+
class: "merchandisable",
|
|
7
|
+
merge: "replace",
|
|
8
|
+
editRole: "marketing",
|
|
9
|
+
overrideFriction: "none",
|
|
10
|
+
snapshot: "on-book",
|
|
11
|
+
};
|
|
12
|
+
describe("variantFallbackChain", () => {
|
|
13
|
+
it("walks 8 steps from most-specific to least-specific", () => {
|
|
14
|
+
const scope = {
|
|
15
|
+
locale: "en-GB",
|
|
16
|
+
audience: "customer",
|
|
17
|
+
market: "UK",
|
|
18
|
+
actor: "customer",
|
|
19
|
+
};
|
|
20
|
+
const chain = variantFallbackChain(scope);
|
|
21
|
+
expect(chain).toHaveLength(8);
|
|
22
|
+
expect(chain[0]).toEqual({ locale: "en-GB", audience: "customer", market: "UK" });
|
|
23
|
+
expect(chain[7]).toEqual({
|
|
24
|
+
locale: OVERLAY_DEFAULT_SCOPE,
|
|
25
|
+
audience: OVERLAY_DEFAULT_SCOPE,
|
|
26
|
+
market: OVERLAY_DEFAULT_SCOPE,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe("applyMerge", () => {
|
|
31
|
+
const policy = (merge) => ({
|
|
32
|
+
path: "field",
|
|
33
|
+
class: "merchandisable",
|
|
34
|
+
merge,
|
|
35
|
+
drift: "none",
|
|
36
|
+
reindex: "none",
|
|
37
|
+
snapshot: "never",
|
|
38
|
+
query: "blob-only",
|
|
39
|
+
localized: false,
|
|
40
|
+
visibility: ["staff"],
|
|
41
|
+
editRole: "none",
|
|
42
|
+
overrideFriction: "none",
|
|
43
|
+
sourceFreshness: null,
|
|
44
|
+
});
|
|
45
|
+
it("replace returns the overlay value", () => {
|
|
46
|
+
expect(applyMerge(policy("replace"), "source", "overlay")).toBe("overlay");
|
|
47
|
+
});
|
|
48
|
+
it("additive-set unions arrays preserving first-occurrence order", () => {
|
|
49
|
+
expect(applyMerge(policy("additive-set"), ["a", "b"], ["b", "c"])).toEqual(["a", "b", "c"]);
|
|
50
|
+
});
|
|
51
|
+
it("additive-list concatenates arrays without dedupe", () => {
|
|
52
|
+
expect(applyMerge(policy("additive-list"), ["a"], ["a", "b"])).toEqual(["a", "a", "b"]);
|
|
53
|
+
});
|
|
54
|
+
it("list-position sparsely overrides positions", () => {
|
|
55
|
+
expect(applyMerge(policy("list-position"), ["x", "y", "z"], { 1: "Y" })).toEqual([
|
|
56
|
+
"x",
|
|
57
|
+
"Y",
|
|
58
|
+
"z",
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
it("source-only throws when an overlay is passed", () => {
|
|
62
|
+
expect(() => applyMerge({ ...policy("replace"), merge: "source-only" }, "source", "overlay")).toThrow(/source-only/);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe("resolveOverlay", () => {
|
|
66
|
+
const policies = defineFieldPolicy([
|
|
67
|
+
{ path: "title", ...merchandisable, localized: true, visibility: ["staff", "customer"] },
|
|
68
|
+
{ path: "description", ...merchandisable, visibility: ["staff", "customer"] },
|
|
69
|
+
{
|
|
70
|
+
path: "internal_notes",
|
|
71
|
+
...merchandisable,
|
|
72
|
+
editRole: "ops",
|
|
73
|
+
visibility: ["staff"],
|
|
74
|
+
},
|
|
75
|
+
]);
|
|
76
|
+
const registry = createFieldPolicyRegistry(policies);
|
|
77
|
+
it("applies an exact-match overlay and records provenance", () => {
|
|
78
|
+
const source = new Map([["title", "Source title"]]);
|
|
79
|
+
const overlays = [
|
|
80
|
+
{
|
|
81
|
+
field_path: "title",
|
|
82
|
+
locale: "en-GB",
|
|
83
|
+
audience: "customer",
|
|
84
|
+
market: "UK",
|
|
85
|
+
value: "Marketing title",
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
const view = resolveOverlay(registry, source, overlays, {
|
|
89
|
+
locale: "en-GB",
|
|
90
|
+
audience: "customer",
|
|
91
|
+
market: "UK",
|
|
92
|
+
actor: "customer",
|
|
93
|
+
});
|
|
94
|
+
expect(view.values.get("title")).toBe("Marketing title");
|
|
95
|
+
expect(view.provenance.get("title")).toEqual({
|
|
96
|
+
locale: "en-GB",
|
|
97
|
+
audience: "customer",
|
|
98
|
+
market: "UK",
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
it("falls back from market=UK to market=default when no UK overlay exists", () => {
|
|
102
|
+
const source = new Map([["title", "Source title"]]);
|
|
103
|
+
const overlays = [
|
|
104
|
+
{
|
|
105
|
+
field_path: "title",
|
|
106
|
+
locale: "en-GB",
|
|
107
|
+
audience: "customer",
|
|
108
|
+
market: OVERLAY_DEFAULT_SCOPE,
|
|
109
|
+
value: "Default-market title",
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
const view = resolveOverlay(registry, source, overlays, {
|
|
113
|
+
locale: "en-GB",
|
|
114
|
+
audience: "customer",
|
|
115
|
+
market: "UK",
|
|
116
|
+
actor: "customer",
|
|
117
|
+
});
|
|
118
|
+
expect(view.values.get("title")).toBe("Default-market title");
|
|
119
|
+
expect(view.provenance.get("title")?.market).toBe(OVERLAY_DEFAULT_SCOPE);
|
|
120
|
+
});
|
|
121
|
+
it("returns the source value when no overlay matches at any fallback level", () => {
|
|
122
|
+
const source = new Map([["title", "Source title"]]);
|
|
123
|
+
const view = resolveOverlay(registry, source, [], {
|
|
124
|
+
locale: "en-GB",
|
|
125
|
+
audience: "customer",
|
|
126
|
+
market: "UK",
|
|
127
|
+
actor: "customer",
|
|
128
|
+
});
|
|
129
|
+
expect(view.values.get("title")).toBe("Source title");
|
|
130
|
+
expect(view.provenance.get("title")).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
it("hides fields not visible to the requesting actor", () => {
|
|
133
|
+
const source = new Map([
|
|
134
|
+
["title", "Source title"],
|
|
135
|
+
["internal_notes", "Internal note"],
|
|
136
|
+
]);
|
|
137
|
+
const view = resolveOverlay(registry, source, [], {
|
|
138
|
+
locale: "en-GB",
|
|
139
|
+
audience: "customer",
|
|
140
|
+
market: OVERLAY_DEFAULT_SCOPE,
|
|
141
|
+
actor: "customer",
|
|
142
|
+
});
|
|
143
|
+
expect(view.values.has("title")).toBe(true);
|
|
144
|
+
expect(view.values.has("internal_notes")).toBe(false);
|
|
145
|
+
expect(view.hidden.has("internal_notes")).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
it("ignores overlays whose field is not in the registry", () => {
|
|
148
|
+
const source = new Map([["title", "Source title"]]);
|
|
149
|
+
const overlays = [
|
|
150
|
+
{
|
|
151
|
+
field_path: "title",
|
|
152
|
+
locale: "en-GB",
|
|
153
|
+
audience: "customer",
|
|
154
|
+
market: OVERLAY_DEFAULT_SCOPE,
|
|
155
|
+
value: "Marketing title",
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
const view = resolveOverlay(registry, source, overlays, {
|
|
159
|
+
locale: "en-GB",
|
|
160
|
+
audience: "customer",
|
|
161
|
+
market: OVERLAY_DEFAULT_SCOPE,
|
|
162
|
+
actor: "customer",
|
|
163
|
+
});
|
|
164
|
+
// Source contains "title", but not "phantom" — the resolver should
|
|
165
|
+
// skip "phantom" because it's not in the registry, but apply title.
|
|
166
|
+
const sourceWithPhantom = new Map([
|
|
167
|
+
["title", "Source title"],
|
|
168
|
+
["phantom", "shouldn't appear"],
|
|
169
|
+
]);
|
|
170
|
+
const phantomView = resolveOverlay(registry, sourceWithPhantom, overlays, {
|
|
171
|
+
locale: "en-GB",
|
|
172
|
+
audience: "customer",
|
|
173
|
+
market: OVERLAY_DEFAULT_SCOPE,
|
|
174
|
+
actor: "customer",
|
|
175
|
+
});
|
|
176
|
+
expect(phantomView.values.has("phantom")).toBe(false);
|
|
177
|
+
expect(view.values.get("title")).toBe("Marketing title");
|
|
178
|
+
});
|
|
179
|
+
});
|