@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.
Files changed (243) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +190 -0
  3. package/dist/adapter/booking-forwarding.d.ts +2 -0
  4. package/dist/adapter/booking-forwarding.d.ts.map +1 -0
  5. package/dist/adapter/booking-forwarding.js +1 -0
  6. package/dist/adapter/channel-push-contracts.d.ts +2 -0
  7. package/dist/adapter/channel-push-contracts.d.ts.map +1 -0
  8. package/dist/adapter/channel-push-contracts.js +1 -0
  9. package/dist/adapter/contract.d.ts +2 -0
  10. package/dist/adapter/contract.d.ts.map +1 -0
  11. package/dist/adapter/contract.js +1 -0
  12. package/dist/adapter/contract.test.d.ts +2 -0
  13. package/dist/adapter/contract.test.d.ts.map +1 -0
  14. package/dist/adapter/contract.test.js +390 -0
  15. package/dist/adapter/provider-contracts.d.ts +2 -0
  16. package/dist/adapter/provider-contracts.d.ts.map +1 -0
  17. package/dist/adapter/provider-contracts.js +1 -0
  18. package/dist/adapter/provider-contracts.test.d.ts +2 -0
  19. package/dist/adapter/provider-contracts.test.d.ts.map +1 -0
  20. package/dist/adapter/provider-contracts.test.js +206 -0
  21. package/dist/adapter/schemas.d.ts +2 -0
  22. package/dist/adapter/schemas.d.ts.map +1 -0
  23. package/dist/adapter/schemas.js +1 -0
  24. package/dist/adapter/schemas.test.d.ts +2 -0
  25. package/dist/adapter/schemas.test.d.ts.map +1 -0
  26. package/dist/adapter/schemas.test.js +344 -0
  27. package/dist/booking-engine/book.d.ts +124 -0
  28. package/dist/booking-engine/book.d.ts.map +1 -0
  29. package/dist/booking-engine/book.js +311 -0
  30. package/dist/booking-engine/cancel.d.ts +40 -0
  31. package/dist/booking-engine/cancel.d.ts.map +1 -0
  32. package/dist/booking-engine/cancel.js +56 -0
  33. package/dist/booking-engine/checkout-finalize.d.ts +146 -0
  34. package/dist/booking-engine/checkout-finalize.d.ts.map +1 -0
  35. package/dist/booking-engine/checkout-finalize.js +132 -0
  36. package/dist/booking-engine/contracts.d.ts +9 -0
  37. package/dist/booking-engine/contracts.d.ts.map +1 -0
  38. package/dist/booking-engine/contracts.js +8 -0
  39. package/dist/booking-engine/contracts.test.d.ts +2 -0
  40. package/dist/booking-engine/contracts.test.d.ts.map +1 -0
  41. package/dist/booking-engine/contracts.test.js +116 -0
  42. package/dist/booking-engine/draft-shape.d.ts +10 -0
  43. package/dist/booking-engine/draft-shape.d.ts.map +1 -0
  44. package/dist/booking-engine/draft-shape.js +9 -0
  45. package/dist/booking-engine/draft-shape.test.d.ts +2 -0
  46. package/dist/booking-engine/draft-shape.test.d.ts.map +1 -0
  47. package/dist/booking-engine/draft-shape.test.js +74 -0
  48. package/dist/booking-engine/drafts-schema.d.ts +302 -0
  49. package/dist/booking-engine/drafts-schema.d.ts.map +1 -0
  50. package/dist/booking-engine/drafts-schema.js +53 -0
  51. package/dist/booking-engine/drafts-service.d.ts +41 -0
  52. package/dist/booking-engine/drafts-service.d.ts.map +1 -0
  53. package/dist/booking-engine/drafts-service.js +108 -0
  54. package/dist/booking-engine/errors.d.ts +81 -0
  55. package/dist/booking-engine/errors.d.ts.map +1 -0
  56. package/dist/booking-engine/errors.js +113 -0
  57. package/dist/booking-engine/index.d.ts +36 -0
  58. package/dist/booking-engine/index.d.ts.map +1 -0
  59. package/dist/booking-engine/index.js +34 -0
  60. package/dist/booking-engine/orders.d.ts +41 -0
  61. package/dist/booking-engine/orders.d.ts.map +1 -0
  62. package/dist/booking-engine/orders.js +49 -0
  63. package/dist/booking-engine/owned-handler.d.ts +166 -0
  64. package/dist/booking-engine/owned-handler.d.ts.map +1 -0
  65. package/dist/booking-engine/owned-handler.js +50 -0
  66. package/dist/booking-engine/owned-handler.test.d.ts +2 -0
  67. package/dist/booking-engine/owned-handler.test.d.ts.map +1 -0
  68. package/dist/booking-engine/owned-handler.test.js +63 -0
  69. package/dist/booking-engine/promotions-contract.d.ts +8 -0
  70. package/dist/booking-engine/promotions-contract.d.ts.map +1 -0
  71. package/dist/booking-engine/promotions-contract.js +7 -0
  72. package/dist/booking-engine/quote-enricher.test.d.ts +12 -0
  73. package/dist/booking-engine/quote-enricher.test.d.ts.map +1 -0
  74. package/dist/booking-engine/quote-enricher.test.js +138 -0
  75. package/dist/booking-engine/quote.d.ts +163 -0
  76. package/dist/booking-engine/quote.d.ts.map +1 -0
  77. package/dist/booking-engine/quote.js +259 -0
  78. package/dist/booking-engine/registry.d.ts +85 -0
  79. package/dist/booking-engine/registry.d.ts.map +1 -0
  80. package/dist/booking-engine/registry.js +118 -0
  81. package/dist/booking-engine/registry.test.d.ts +2 -0
  82. package/dist/booking-engine/registry.test.d.ts.map +1 -0
  83. package/dist/booking-engine/registry.test.js +132 -0
  84. package/dist/booking-engine/routes-contracts.d.ts +169 -0
  85. package/dist/booking-engine/routes-contracts.d.ts.map +1 -0
  86. package/dist/booking-engine/routes-contracts.js +63 -0
  87. package/dist/booking-engine/routes.d.ts +7 -0
  88. package/dist/booking-engine/routes.d.ts.map +1 -0
  89. package/dist/booking-engine/routes.js +443 -0
  90. package/dist/booking-engine/routes.test.d.ts +2 -0
  91. package/dist/booking-engine/routes.test.d.ts.map +1 -0
  92. package/dist/booking-engine/routes.test.js +304 -0
  93. package/dist/booking-engine/schema.d.ts +455 -0
  94. package/dist/booking-engine/schema.d.ts.map +1 -0
  95. package/dist/booking-engine/schema.js +75 -0
  96. package/dist/booking-engine/snapshot-content.d.ts +120 -0
  97. package/dist/booking-engine/snapshot-content.d.ts.map +1 -0
  98. package/dist/booking-engine/snapshot-content.js +110 -0
  99. package/dist/booking-engine/snapshot-content.test.d.ts +2 -0
  100. package/dist/booking-engine/snapshot-content.test.d.ts.map +1 -0
  101. package/dist/booking-engine/snapshot-content.test.js +213 -0
  102. package/dist/booking-engine/sync.d.ts +136 -0
  103. package/dist/booking-engine/sync.d.ts.map +1 -0
  104. package/dist/booking-engine/sync.js +177 -0
  105. package/dist/booking-engine/sync.test.d.ts +2 -0
  106. package/dist/booking-engine/sync.test.d.ts.map +1 -0
  107. package/dist/booking-engine/sync.test.js +377 -0
  108. package/dist/contract.d.ts +2 -0
  109. package/dist/contract.d.ts.map +1 -0
  110. package/dist/contract.js +1 -0
  111. package/dist/contract.test.d.ts +2 -0
  112. package/dist/contract.test.d.ts.map +1 -0
  113. package/dist/contract.test.js +107 -0
  114. package/dist/drift/events.d.ts +2 -0
  115. package/dist/drift/events.d.ts.map +1 -0
  116. package/dist/drift/events.js +1 -0
  117. package/dist/drift/events.test.d.ts +2 -0
  118. package/dist/drift/events.test.d.ts.map +1 -0
  119. package/dist/drift/events.test.js +100 -0
  120. package/dist/embeddings/contract.d.ts +85 -0
  121. package/dist/embeddings/contract.d.ts.map +1 -0
  122. package/dist/embeddings/contract.js +42 -0
  123. package/dist/embeddings/contract.test.d.ts +2 -0
  124. package/dist/embeddings/contract.test.d.ts.map +1 -0
  125. package/dist/embeddings/contract.test.js +30 -0
  126. package/dist/embeddings/gemini.d.ts +110 -0
  127. package/dist/embeddings/gemini.d.ts.map +1 -0
  128. package/dist/embeddings/gemini.js +118 -0
  129. package/dist/embeddings/gemini.test.d.ts +2 -0
  130. package/dist/embeddings/gemini.test.d.ts.map +1 -0
  131. package/dist/embeddings/gemini.test.js +132 -0
  132. package/dist/embeddings/model-registry.d.ts +62 -0
  133. package/dist/embeddings/model-registry.d.ts.map +1 -0
  134. package/dist/embeddings/model-registry.js +78 -0
  135. package/dist/embeddings/model-registry.test.d.ts +2 -0
  136. package/dist/embeddings/model-registry.test.d.ts.map +1 -0
  137. package/dist/embeddings/model-registry.test.js +81 -0
  138. package/dist/embeddings/openai.d.ts +81 -0
  139. package/dist/embeddings/openai.d.ts.map +1 -0
  140. package/dist/embeddings/openai.js +123 -0
  141. package/dist/embeddings/openai.test.d.ts +2 -0
  142. package/dist/embeddings/openai.test.d.ts.map +1 -0
  143. package/dist/embeddings/openai.test.js +164 -0
  144. package/dist/events/taxonomy.d.ts +158 -0
  145. package/dist/events/taxonomy.d.ts.map +1 -0
  146. package/dist/events/taxonomy.js +99 -0
  147. package/dist/events/taxonomy.test.d.ts +2 -0
  148. package/dist/events/taxonomy.test.d.ts.map +1 -0
  149. package/dist/events/taxonomy.test.js +48 -0
  150. package/dist/index.d.ts +27 -0
  151. package/dist/index.d.ts.map +1 -0
  152. package/dist/index.js +39 -0
  153. package/dist/indexer/contract.d.ts +203 -0
  154. package/dist/indexer/contract.d.ts.map +1 -0
  155. package/dist/indexer/contract.js +16 -0
  156. package/dist/indexer/typesense-search-query.d.ts +31 -0
  157. package/dist/indexer/typesense-search-query.d.ts.map +1 -0
  158. package/dist/indexer/typesense-search-query.js +185 -0
  159. package/dist/indexer/typesense.d.ts +105 -0
  160. package/dist/indexer/typesense.d.ts.map +1 -0
  161. package/dist/indexer/typesense.js +394 -0
  162. package/dist/indexer/typesense.test.d.ts +2 -0
  163. package/dist/indexer/typesense.test.d.ts.map +1 -0
  164. package/dist/indexer/typesense.test.js +253 -0
  165. package/dist/overlay/resolver.d.ts +101 -0
  166. package/dist/overlay/resolver.d.ts.map +1 -0
  167. package/dist/overlay/resolver.js +167 -0
  168. package/dist/overlay/resolver.test.d.ts +2 -0
  169. package/dist/overlay/resolver.test.d.ts.map +1 -0
  170. package/dist/overlay/resolver.test.js +179 -0
  171. package/dist/overlay/schema.d.ts +266 -0
  172. package/dist/overlay/schema.d.ts.map +1 -0
  173. package/dist/overlay/schema.js +71 -0
  174. package/dist/provenance.d.ts +2 -0
  175. package/dist/provenance.d.ts.map +1 -0
  176. package/dist/provenance.js +1 -0
  177. package/dist/schema-sourced-entries.d.ts +344 -0
  178. package/dist/schema-sourced-entries.d.ts.map +1 -0
  179. package/dist/schema-sourced-entries.js +75 -0
  180. package/dist/schema.d.ts +21 -0
  181. package/dist/schema.d.ts.map +1 -0
  182. package/dist/schema.js +20 -0
  183. package/dist/search/federate.d.ts +58 -0
  184. package/dist/search/federate.d.ts.map +1 -0
  185. package/dist/search/federate.js +103 -0
  186. package/dist/search/federate.test.d.ts +2 -0
  187. package/dist/search/federate.test.d.ts.map +1 -0
  188. package/dist/search/federate.test.js +146 -0
  189. package/dist/search/rerank.d.ts +77 -0
  190. package/dist/search/rerank.d.ts.map +1 -0
  191. package/dist/search/rerank.js +68 -0
  192. package/dist/search/rerank.test.d.ts +2 -0
  193. package/dist/search/rerank.test.d.ts.map +1 -0
  194. package/dist/search/rerank.test.js +60 -0
  195. package/dist/search/routes.d.ts +144 -0
  196. package/dist/search/routes.d.ts.map +1 -0
  197. package/dist/search/routes.js +288 -0
  198. package/dist/search/routes.test.d.ts +2 -0
  199. package/dist/search/routes.test.d.ts.map +1 -0
  200. package/dist/search/routes.test.js +322 -0
  201. package/dist/search/semantic.d.ts +63 -0
  202. package/dist/search/semantic.d.ts.map +1 -0
  203. package/dist/search/semantic.js +75 -0
  204. package/dist/search/semantic.test.d.ts +2 -0
  205. package/dist/search/semantic.test.d.ts.map +1 -0
  206. package/dist/search/semantic.test.js +143 -0
  207. package/dist/services/build-indexer-document.test.d.ts +2 -0
  208. package/dist/services/build-indexer-document.test.d.ts.map +1 -0
  209. package/dist/services/build-indexer-document.test.js +102 -0
  210. package/dist/services/content-service.d.ts +125 -0
  211. package/dist/services/content-service.d.ts.map +1 -0
  212. package/dist/services/content-service.js +139 -0
  213. package/dist/services/content-service.test.d.ts +2 -0
  214. package/dist/services/content-service.test.d.ts.map +1 -0
  215. package/dist/services/content-service.test.js +322 -0
  216. package/dist/services/indexer-service.d.ts +109 -0
  217. package/dist/services/indexer-service.d.ts.map +1 -0
  218. package/dist/services/indexer-service.js +123 -0
  219. package/dist/services/indexer-service.test.d.ts +2 -0
  220. package/dist/services/indexer-service.test.d.ts.map +1 -0
  221. package/dist/services/indexer-service.test.js +176 -0
  222. package/dist/services/overlay-service.d.ts +108 -0
  223. package/dist/services/overlay-service.d.ts.map +1 -0
  224. package/dist/services/overlay-service.js +211 -0
  225. package/dist/services/overlay-service.test.d.ts +2 -0
  226. package/dist/services/overlay-service.test.d.ts.map +1 -0
  227. package/dist/services/overlay-service.test.js +79 -0
  228. package/dist/services/snapshot-builder.test.d.ts +2 -0
  229. package/dist/services/snapshot-builder.test.d.ts.map +1 -0
  230. package/dist/services/snapshot-builder.test.js +93 -0
  231. package/dist/services/snapshot-service.d.ts +78 -0
  232. package/dist/services/snapshot-service.d.ts.map +1 -0
  233. package/dist/services/snapshot-service.js +165 -0
  234. package/dist/services/sourced-entry-service.d.ts +142 -0
  235. package/dist/services/sourced-entry-service.d.ts.map +1 -0
  236. package/dist/services/sourced-entry-service.js +203 -0
  237. package/dist/services/sourced-entry-service.test.d.ts +10 -0
  238. package/dist/services/sourced-entry-service.test.d.ts.map +1 -0
  239. package/dist/services/sourced-entry-service.test.js +66 -0
  240. package/dist/snapshot/schema.d.ts +362 -0
  241. package/dist/snapshot/schema.d.ts.map +1 -0
  242. package/dist/snapshot/schema.js +102 -0
  243. 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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=resolver.test.d.ts.map
@@ -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
+ });